diff --git a/.env.example b/.env.example index 4c83db1f3b48..893bda62110a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,13 @@ # Hermes Agent Environment Configuration # Copy this file to .env and fill in your API keys +# ============================================================================= +# LLM PROVIDER (Fireworks AI) +# ============================================================================= +# Get your key at: https://app.fireworks.ai/settings/users/api-keys +# Address models directly by catalog ID, e.g. +# accounts/fireworks/models/kimi-k2p6, accounts/fireworks/models/glm-5p2 +# FIREWORKS_API_KEY= # ============================================================================= # LLM PROVIDER (OpenRouter) # ============================================================================= @@ -108,6 +115,10 @@ # HF_BASE_URL=https://router.huggingface.co/v1 # Override default base URL # OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL +# DeepInfra — 100+ top open models, pay-per-use. +# Get your key at: https://deepinfra.com/dash/api_keys +# DEEPINFRA_API_KEY= + # ============================================================================= # LLM PROVIDER (Qwen OAuth) # ============================================================================= @@ -125,6 +136,15 @@ # Optional base URL override: # XIAOMI_BASE_URL=https://api.xiaomimimo.com/v1 +# ============================================================================= +# LLM PROVIDER (Upstage Solar) +# ============================================================================= +# Upstage provides access to Upstage Solar models. +# Get your key at: https://console.upstage.ai/api-keys +# UPSTAGE_API_KEY=your_key_here +# Optional base URL override: +# UPSTAGE_BASE_URL=https://api.upstage.ai/v1 + # ============================================================================= # TOOL API KEYS # ============================================================================= diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index 268b0aa103c8..751190daf19b 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -10,7 +10,7 @@ outputs: description: Run Python tests / ruff / ty / windows-footguns. value: ${{ steps.classify.outputs.python }} frontend: - description: Run the TypeScript typecheck matrix + desktop build. + description: Run the TypeScript testing matrix + desktop build. value: ${{ steps.classify.outputs.frontend }} docker_meta: description: Docker setup and meta files have changed. @@ -27,6 +27,9 @@ outputs: mcp_catalog: description: Require MCP catalog security review label. value: ${{ steps.classify.outputs.mcp_catalog }} + ci_review: + description: Require CI-sensitive file review label. + value: ${{ steps.classify.outputs.ci_review }} runs: using: composite diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab6012016536..d5b4fab2dd86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: deps: ${{ steps.classify.outputs.deps }} docker_meta: ${{ steps.classify.outputs.docker_meta }} mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }} + ci_review: ${{ steps.classify.outputs.ci_review }} event_name: ${{ github.event_name }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -65,16 +66,17 @@ jobs: lint: name: Python lints needs: detect - if: needs.detect.outputs.python == 'true' + if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true' uses: ./.github/workflows/lint.yml with: event_name: ${{ needs.detect.outputs.event_name }} + ci_review: ${{ needs.detect.outputs.ci_review == 'true' }} - typecheck: - name: TypeScript + js-tests: + name: JS & TS checks needs: detect if: needs.detect.outputs.frontend == 'true' - uses: ./.github/workflows/typecheck.yml + uses: ./.github/workflows/js-tests.yml docs-site: name: Docs Site @@ -139,7 +141,7 @@ jobs: needs: - tests - lint - - typecheck + - js-tests - docs-site - history-check - contributor-check @@ -154,14 +156,18 @@ jobs: steps: - name: Evaluate job results env: - RESULTS: ${{ toJSON(needs.*.result) }} + NEEDS: ${{ toJSON(needs) }} run: | - echo "$RESULTS" | python3 -c " + echo "$NEEDS" | python3 -c " import json, sys - results = json.load(sys.stdin) - failed = [r for r in results if r == 'failure'] + needs = json.load(sys.stdin) + failed = [name for name, info in needs.items() if info['result'] == 'failure'] + for name, info in sorted(needs.items()): + result = info['result'] + icon = '✅' if result in ('success', 'skipped') else '❌' + print(f'{icon} {name}: {result}') if failed: - print(f'::error::{len(failed)} job(s) failed') + print(f'::error::{len(failed)} job(s) failed: {\", \".join(failed)}') sys.exit(1) print('All checks passed (or were skipped)') " diff --git a/.github/workflows/js-autofix.yml b/.github/workflows/js-autofix.yml new file mode 100644 index 000000000000..ae6caab0fa96 --- /dev/null +++ b/.github/workflows/js-autofix.yml @@ -0,0 +1,235 @@ +name: auto-fix lint issues & formatting + +# On push to main (or manual trigger), run `npm run fix` on each workspace +# package and apply any changes via a PR. +# +# Fixable lint issues (import sorting, unused imports, curly braces, etc.) are +# auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint +# check in typecheck.yml fails only when un-fixable errors remain. +# +# Pushes made by GITHUB_TOKEN don't trigger further workflow runs, so there's +# no infinite loop — the push to bot/js-autofix and the resulting squash merge +# to main are both made by GITHUB_TOKEN. +# +# ── Security model: two-job split ─────────────────────────────────────────── +# +# The eslint process executes repo code (eslint.config.mjs, package.json +# scripts, installed plugins). To prevent a malicious PR from getting arbitrary +# code execution on a runner with push access, the work is split: +# +# 1. generate-patch (unprivileged, contents: read only) +# Checks out, installs deps, runs eslint --fix, produces a .patch artifact. +# Worst case: malicious code runs here on an ephemeral runner with zero +# push permissions. +# +# 2. apply-patch (privileged, contents: write + pull-requests: write) +# Checks out, downloads the patch artifact, applies it, pushes to the +# bot/js-autofix branch, creates/updates a PR, and enables auto-merge. +# This job never runs npm, never installs anything, never executes any +# repo code. The only input it trusts is the patch artifact. +# The PR auto-merges (squash) once CI passes. If CI fails or main moves, +# the PR is auto-closed and the branch deleted — the next run re-applies +# on the current state. + +on: + push: + branches: [main] + paths: + - '**/*.js' + - '**/*.cjs' + - '**/*.mjs' + - '**/*.ts' + - '**/*.tsx' + - 'package.json' + - 'package-lock.json' + workflow_dispatch: + +permissions: + contents: read # default; apply-patch job overrides to write + +concurrency: + group: ts-autofix-${{ github.ref }} + cancel-in-progress: true + +jobs: + generate-patch: + name: Generate eslint --fix patch + runs-on: ubuntu-latest + timeout-minutes: 5 + # No permissions override → inherits workflow-level contents: read. + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + + # --ignore-scripts: eslint only needs TS sources + eslint packages. + - uses: ./.github/actions/retry + with: + command: npm ci --ignore-scripts + + - name: npm run fix in all workspaces + # continue-on-error: if un-fixable errors exist on main, we still want + # to commit whatever fixes were applied. The PR-time check in + # typecheck.yml is what blocks un-fixable errors from landing. + continue-on-error: true + run: npm run fix + + - name: Produce patch + run: | + if git diff --quiet; then + echo "No fixes needed." + # Empty patch signals "nothing to do" to apply-patch. + : > js-fix.patch + else + git diff > js-fix.patch + echo "Patch size: $(wc -c < js-fix.patch) bytes" + + # Reject patches that touch anything outside JS/TS/JSON sources. + # `npm run fix` should only ever modify those; anything else means + # eslint/prettier or a plugin went rogue and we refuse to ship it. + BAD=$(git diff --name-only | grep -vE '\.(js|cjs|mjs|ts|tsx|json)$' || true) + if [ -n "$BAD" ]; then + echo "::error::Refusing to upload patch — touches disallowed files:" + echo "$BAD" + exit 1 + fi + fi + + - name: Upload patch artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: js-fix-patch + path: js-fix.patch + retention-days: 1 + include-hidden-files: true + + apply-patch: + name: Apply patch + needs: generate-patch + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write # needed to push to bot/js-autofix + pull-requests: write # needed for PR creation + auto-merge + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download patch + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: js-fix-patch + # ${{ runner.temp }} expands in with: params (shell-style $VAR does not). + # download-artifact's path is a *directory* — the artifact's js-fix.patch + # file lands inside it, so $RUNNER_TEMP/js-fix.patch resolves correctly + # in the run step below. + path: ${{ runner.temp }} + + - name: Apply patch and push to bot branch + env: + BOT_BRANCH: bot/js-autofix + run: | + set -euo pipefail + + # Empty patch = nothing to do. + if [ ! -s "$RUNNER_TEMP/js-fix.patch" ]; then + echo "Patch is empty. No fixes to apply." + exit 0 + fi + + # Apply the patch produced by the unprivileged job. + git apply --check "$RUNNER_TEMP/js-fix.patch" || { + echo "::error::Patch does not apply cleanly. Branch may have moved." + exit 1 + } + git apply "$RUNNER_TEMP/js-fix.patch" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fmt(js): \`npm run fix\` on merge" + + # Push to the dedicated bot branch. Force-push is safe here: + # bot/js-autofix is a bot-only branch that gets rewritten each run. + # If the branch was deleted after a previous PR merge, this + # recreates it. + git push --force origin HEAD:"$BOT_BRANCH" + + - name: Create/update PR and enable auto-merge + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BOT_BRANCH: bot/js-autofix + run: | + set -euo pipefail + + # Create PR if one doesn't exist. If it already exists, the + # force-push above already updated it with the latest fixes. + PR_NUM=$(gh pr list --head "$BOT_BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || true) + if [ -z "$PR_NUM" ]; then + # gh pr create prints the PR URL. Extract the number from it + # (https://github.com///pull/). + PR_URL=$(gh pr create \ + --head "$BOT_BRANCH" --base main \ + --title 'fmt(js): `npm run fix` auto-fix' \ + --body 'Auto-generated by the `auto-fix lint issues & formatting` workflow. Auto-merges (squash) once CI passes. If CI fails or `main` moves, the PR is auto-closed and the branch deleted — the next run re-applies on the current state.') + PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$') + fi + + # Enable auto-merge (squash). If already enabled, this is a no-op. + gh pr merge "$PR_NUM" --auto --squash || true + + - name: Wait for merge, auto-close on failure or stale + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + START_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + PR_NUM=$(gh pr list --head bot/js-autofix --state open --json number --jq '.[0].number' 2>/dev/null || true) + if [ -z "$PR_NUM" ]; then + echo "No open PR. Nothing to wait for." + exit 0 + fi + + echo "Waiting for PR #$PR_NUM to merge..." + + # Poll every 15s for up to ~10 minutes. Auto-merge will handle the + # PR even if this job times out — the polling is for cleanup only + # (auto-close on CI failure, conflicts, or main moving). + for i in $(seq 1 40); do + sleep 15 + + STATE=$(gh pr view "$PR_NUM" --json state --jq '.state') + if [ "$STATE" = "MERGED" ] || [ "$STATE" = "CLOSED" ]; then + echo "PR #$PR_NUM is $STATE." + exit 0 + fi + + # If main moved, close the PR — the next workflow run re-applies + # on the current state. + CURRENT_SHA=$(gh api "repos/${{ github.repository }}/branches/main" --jq '.commit.sha') + if [ "$CURRENT_SHA" != "$START_SHA" ]; then + echo "Main moved ($START_SHA → $CURRENT_SHA). Closing stale PR." + gh pr close "$PR_NUM" --delete-branch + exit 0 + fi + + # If CI checks failed, close + delete the branch. + if gh pr checks "$PR_NUM" 2>/dev/null | grep -qi "fail"; then + echo "CI failed on PR #$PR_NUM. Closing + deleting branch." + gh pr close "$PR_NUM" --delete-branch + exit 0 + fi + + # If PR is conflicted, close + delete the branch. + MERGEABLE=$(gh pr view "$PR_NUM" --json mergeable --jq '.mergeable') + if [ "$MERGEABLE" = "CONFLICTING" ]; then + echo "PR #$PR_NUM is conflicted. Closing + deleting branch." + gh pr close "$PR_NUM" --delete-branch + exit 0 + fi + done + + echo "Timeout reached. Auto-merge will handle PR #$PR_NUM if CI passes." diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml new file mode 100644 index 000000000000..f0a384bafe60 --- /dev/null +++ b/.github/workflows/js-tests.yml @@ -0,0 +1,49 @@ +# .github/workflows/js-tests.yml +name: JS Tests + +on: + workflow_call: + +jobs: + workspaces: + name: List npm workspaces + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.set-matrix.outputs.packages }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + - uses: ./.github/actions/retry + with: + command: npm ci --ignore-scripts + - id: set-matrix + run: | + PACKAGES=$(npm query .workspace | jq -c '[.[].location]') + if [ "$PACKAGES" = "[]" ] || [ -z "$PACKAGES" ]; then + echo "::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently)." + exit 1 + fi + echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT" + + check: + name: Typecheck & Test + needs: workspaces + runs-on: ubuntu-latest + strategy: + matrix: + package: ${{ fromJson(needs.workspaces.outputs.packages) }} + fail-fast: false # report all failures, not just the first one + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + - uses: ./.github/actions/retry + with: + command: npm ci + - run: npm run --prefix ${{ matrix.package }} check + - run: npm run --prefix ${{ matrix.package }} fix diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index beb3a07abaee..e88294dd94ed 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,6 +15,10 @@ on: description: The event name from the calling orchestrator (pull_request or push). type: string required: true + ci_review: + description: Whether CI-sensitive files (eslint config, workflows, actions) changed and require a review label. + type: boolean + default: false permissions: contents: read @@ -158,3 +162,111 @@ jobs: - name: Run footgun checker run: python scripts/check-windows-footguns.py --all + + ci-review: + # Require explicit maintainer review when CI-sensitive files change: + # eslint config, workflow YAMLs, or composite actions. These files + # influence what code the js-autofix job executes and pushes to + # main, so a malicious PR could inject arbitrary code via a custom eslint + # rule's `fix` function. The label gate ensures a human reviews before + # merge. Mirrors the mcp-catalog-reviewed pattern in supply-chain-audit.yml. + name: CI-sensitive file review + if: inputs.event_name == 'pull_request' && inputs.ci_review + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Require ci-reviewed label + id: label-check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + PR="${{ github.event.pull_request.number }}" + LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true) + if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then + echo "reviewed=true" >> "$GITHUB_OUTPUT" + echo "ci-reviewed label present." + exit 0 + fi + echo "reviewed=false" >> "$GITHUB_OUTPUT" + + # On failure: find the bot's previous comment and edit it, or create + # a new one if none exists. Using an HTML comment marker so we can + # locate it reliably across runs without parsing the body text. + # Skipped on fork PRs — GITHUB_TOKEN is read-only there, so the API + # call would fail. The label gate still holds via the step below. + - name: Post or update review warning + if: steps.label-check.outputs.reviewed != 'true' && github.event.pull_request.head.repo.fork != true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + PR="${{ github.event.pull_request.number }}" + MARKER="" + BODY="${MARKER} + ## ⚠️ CI-sensitive file review required + + This PR changes CI-sensitive files (eslint config, workflow YAMLs, + or composite actions). These files influence what code the + js-autofix job executes and pushes to main. + + A maintainer should verify: + - no new eslint rules with custom \`fix\` functions that write outside linted paths, + - no workflow changes that widen permissions or remove guards, + - no composite action changes that alter what gets executed. + + After review, add the \`ci-reviewed\` label and re-run this check." + + # Find an existing comment with our marker. + COMMENT_ID=$(gh api \ + "repos/${{ github.repository }}/issues/${PR}/comments" \ + --paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ + | head -1 || true) + + if [ -n "$COMMENT_ID" ]; then + gh api --method PATCH \ + "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \ + -f body="$BODY" + else + gh pr comment "$PR" --body "$BODY" + fi + + # Fail the job when the label is missing — always runs (including + # fork PRs) so the security gate holds even when the comment step + # was skipped above. + - name: Fail on missing label + if: steps.label-check.outputs.reviewed != 'true' + run: | + echo "::error::CI-sensitive changes require the ci-reviewed label." + exit 1 + + # On success: if a previous warning comment exists, edit it to show + # the review passed so the PR doesn't have a stale ⚠️ sitting around. + # Skipped on fork PRs — no comment was ever posted to update. + - name: Update previous warning to passed + if: steps.label-check.outputs.reviewed == 'true' && github.event.pull_request.head.repo.fork != true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + PR="${{ github.event.pull_request.number }}" + MARKER="" + + # Find an existing comment with our marker. + COMMENT_ID=$(gh api \ + "repos/${{ github.repository }}/issues/${PR}/comments" \ + --paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ + | head -1 || true) + + if [ -n "$COMMENT_ID" ]; then + BODY="${MARKER} + ## ✅ CI-sensitive file review passed + + The \`ci-reviewed\` label is present on this PR." + + gh api --method PATCH \ + "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \ + -f body="$BODY" + fi diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml deleted file mode 100644 index dd2906629b01..000000000000 --- a/.github/workflows/typecheck.yml +++ /dev/null @@ -1,51 +0,0 @@ -# .github/workflows/typecheck.yml -name: Typecheck - -on: - workflow_call: - -jobs: - typecheck: - name: Check TypeScript - runs-on: ubuntu-latest - strategy: - matrix: - package: - [ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared] - fail-fast: false # report all failures, not just the first one - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22 - cache: npm - # --ignore-scripts: typecheck only needs the TS sources + type defs, not - # native builds. Skipping install scripts drops node-pty's node-gyp - # header fetch — the transient flake that killed this job pre-`tsc` — and - # is faster. retry covers the remaining registry blips. - - uses: ./.github/actions/retry - with: - command: npm ci --ignore-scripts - - run: npm run --prefix ${{ matrix.package }} typecheck - - # Production build of the desktop renderer. `typecheck` runs `tsc` only, - # which does NOT exercise Vite/Rolldown module resolution — so an - # unresolvable package export (e.g. a transitive @assistant-ui/tap that no - # longer exports "./react-shim") slips past typecheck and only explodes when - # users build apps/desktop from source on install/update. Run the real - # `vite build` here so that class of break fails in CI instead. - desktop-build: - name: Build desktop app - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22 - cache: npm - # Keep install scripts here: the production build may need node-pty's - # native binary. retry handles the transient install-time fetch flakes. - - uses: ./.github/actions/retry - with: - command: npm ci - - run: npm run --prefix apps/desktop build diff --git a/.gitignore b/.gitignore index e4240ea36e75..6f1b3be6d92b 100644 --- a/.gitignore +++ b/.gitignore @@ -68,8 +68,19 @@ environments/benchmarks/evals/ hermes_cli/web_dist/ apps/desktop/build/ apps/desktop/dist/ + +# tsc-emitted artifacts (a stray `tsc -b` compiles into src/, and vite then +# resolves the stale .js OVER the .tsx — never track these) +apps/desktop/src/**/*.js +apps/desktop/src/**/*.js.map +apps/desktop/src/**/*.d.ts +!apps/desktop/src/global.d.ts +!apps/desktop/src/vite-env.d.ts +apps/shared/src/**/*.js +apps/shared/src/**/*.js.map +apps/shared/src/**/*.d.ts apps/desktop/release/ -apps/desktop/*.tsbuildinfo +*.tsbuildinfo # Web UI assets — synced from @nous-research/ui at build time via # `npm run sync-assets` (see web/package.json). @@ -119,6 +130,9 @@ docs/superpowers/* # treat it as a local edit and autostash it on every run (#38529). .hermes-bootstrap-complete +# Persistent dev sandbox dir (scripts/dev-sandbox.sh --persistent) +.hermes-sandbox/ + # Interrupted-update breadcrumb + recovery lock written next to the shared venv # by `hermes update` / launch-time self-heal. Runtime state, never a code change # — ignore so `git status` stays clean and update's autostash skips them. diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000000..ca429279c2f0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +# Lockfiles must never be reformatted — main has a repo rule requiring +# team approval when lockfiles change, so an autofix PR touching one +# would hang waiting for review. +package-lock.json diff --git a/apps/desktop/.prettierrc b/.prettierrc similarity index 100% rename from apps/desktop/.prettierrc rename to .prettierrc diff --git a/AGENTS.md b/AGENTS.md index e1dbaaa5c43c..bb2a78f2114a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -491,18 +491,18 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes ### Electron Desktop Chat App (`apps/desktop/`) -A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`. +A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.ts` + `backendSupportsServe()` in `electron/main.ts`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. For scoped Desktop architecture, state, resolver, transport, and testing rules, read `apps/desktop/AGENTS.md`. **Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline: - **Backend already provides everything.** `tui_gateway/server.py` `commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) both include built-in commands, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` / `get_skill_commands()`). The desktop app does not need a new RPC to see skills. -- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMANDS` (the ~19 built-ins shown in the palette) plus block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover. +- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMAND_SPECS` (the built-ins and their Desktop surfaces) plus `NO_DESKTOP_SURFACE` block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover. - `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run. - `isDesktopSlashSuggestion(name)` — gates **discovery/completion**. Used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` (empty-query catalog filter + typed-query `complete.slash` filter) and by `filterDesktopCommandsCatalog`. - `isDesktopSlashExtensionCommand(name)` — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.) -- **Dispatch** lives in `app/session/hooks/use-prompt-actions.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt. +- **Dispatch** lives in `app/session/hooks/use-prompt-actions/slash.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt. -**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: `apps/desktop/src/lib/desktop-slash-commands.test.ts` (run via the repo-root `vitest`, since `apps/desktop` resolves deps from the root workspace install). +**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: from `apps/desktop`, run `npx vitest run src/lib/desktop-slash-commands.test.ts` (workspace dependencies are installed at the repo root). --- @@ -1278,6 +1278,7 @@ def profile_env(tmp_path, monkeypatch): ## Testing +### Python **ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8, `-n auto` xdist workers, in-tree subprocess-isolation plugin). Direct `pytest` @@ -1291,12 +1292,12 @@ scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test scripts/run_tests.sh -v --tb=long # pass-through pytest flags ``` -### Subprocess-per-test-file isolation +#### Subprocess-per-test-file isolation Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and ContextVars from one test file cannot leak into the next. -### Why the wrapper +#### Why the wrapper | | Without wrapper | With wrapper | | ------------------- | ------------------------------------------- | ----------------------------------------- | @@ -1305,6 +1306,17 @@ ContextVars from one test file cannot leak into the next. | Timezone | Local TZ (PDT etc.) | UTC | | Locale | Whatever is set | C.UTF-8 | +### Where to place what tests + +The CI change classifier (`scripts/ci/classify_changes.py`) runs specific jobs based on what files changed. A Python test that asserts +about the contents of `package.json`, `package-lock.json`, `.ts`/`.tsx` +source, or any other JS-side artifact will not run on a PR that only touches +those files. This means a regression can go green on a PR and red on `main` (where the +classifier fails open and runs everything). + +Any test that reads or asserts about `package.json`, +`package-lock.json`, `tsconfig.json`, `.ts`/`.tsx`/`.js`/`.mjs`/`.cjs` +source files configuration belongs in the JS (vitest) test suite, not in `tests/*.py`. ### Don't write change-detector tests @@ -1354,3 +1366,58 @@ not the specific names. Reviewers should reject new change-detector tests; authors should convert them into invariants before re-requesting review. + +### Never read source code in tests + +A test that reads a source file's text is testing *the shape of the +source code*, not its behavior. This is a hard antipattern, banned outright. +Any test that reads a .py, .ts, .tsx, etc., file is suspect. + +**Why it's actively harmful, not just weak:** + +- It passes when the implementation is subtly broken (the regex matches a + call site that exists but is wired wrong) and fails when a correct + refactor changes formatting, variable names, or control flow with + identical runtime behavior. Both directions of failure are wrong. +- It can't be run against a built/bundled/minified artifact, so it silently + stops testing anything the moment code moves, gets renamed, or a + dependency reformats it. +- It actively blocks refactors: reviewers see "keeps a pattern intact" tests + fail during pure structural cleanup with no behavior change, and either + hand-wave the failure (dangerous) or waste time updating regexes that add + nothing (waste). +- It gives false confidence. a green suite full of source-regex tests + looks like coverage but has never once executed the code path it claims + to guard. + +**Do not write:** + +```ts +const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8') + +test('backend spawn hides the Windows console', () => { + assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/) +}) +``` + +**Do write — extract the logic into a small pure/DI-testable function and +call it for real:** + +```ts +// backend-spawn.ts +export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') { + if (!isWindows || 'windowsHide' in options) return options + return { ...options, windowsHide: true } +} + +// backend-spawn.test.ts +test('windowsHide defaults to true on Windows, is left alone elsewhere', () => { + assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true) + assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined) + assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false) +}) +``` + +If the logic lives inline in a god-file (`main.ts`, `cli.py`, +`gateway/run.py`) and extracting it feels disruptive: that's the actual +signal to do the extraction, not to regex around it. diff --git a/acp_adapter/permissions.py b/acp_adapter/permissions.py index 29bd101edd99..5f29a96725cc 100644 --- a/acp_adapter/permissions.py +++ b/acp_adapter/permissions.py @@ -38,19 +38,22 @@ def _permission_option_supports_kind(kind: str) -> bool: return True -def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption]: +def _build_permission_options( + *, allow_permanent: bool, smart_denied: bool = False, +) -> list[PermissionOption]: """Return ACP options that match Hermes approval semantics.""" - options = [ - PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"), - PermissionOption( + options = [PermissionOption( + option_id="allow_once", kind="allow_once", name="Allow once", + )] + if not smart_denied: + options.append(PermissionOption( option_id="allow_session", # ACP has no session-scoped kind, so use the closest persistent # hint while keeping Hermes semantics in the option id. kind="allow_always", name="Allow for session", - ), - ] - if allow_permanent: + )) + if allow_permanent and not smart_denied: options.append( PermissionOption( option_id="allow_always", @@ -59,7 +62,7 @@ def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption ), ) options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny")) - if _permission_option_supports_kind("reject_always"): + if not smart_denied and _permission_option_supports_kind("reject_always"): options.append( PermissionOption( option_id="deny_always", @@ -129,11 +132,15 @@ def make_approval_callback( description: str, *, allow_permanent: bool = True, + smart_denied: bool = False, **_: object, ) -> str: from agent.async_utils import safe_schedule_threadsafe - options = _build_permission_options(allow_permanent=allow_permanent) + options = _build_permission_options( + allow_permanent=allow_permanent, + smart_denied=smart_denied, + ) tool_call = _build_permission_tool_call(command, description) coro = request_permission_fn( diff --git a/acp_adapter/session.py b/acp_adapter/session.py index b048fae510f8..a51c4c58aa2d 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -26,31 +26,18 @@ from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) -def _win_path_to_wsl(path: str) -> str | None: - """Convert a Windows drive path to its WSL /mnt//... equivalent.""" - match = re.match(r"^([A-Za-z]):[\\/](.*)$", path) - if not match: - return None - drive = match.group(1).lower() - tail = match.group(2).replace("\\", "/") - return f"/mnt/{drive}/{tail}" - - def _translate_acp_cwd(cwd: str) -> str: """Translate Windows ACP cwd values when Hermes itself is running in WSL. Windows ACP clients can launch ``hermes acp`` inside WSL while still sending - editor workspaces as Windows drive paths such as ``E:\\Projects``. Store - and execute against the WSL mount path so agents, tools, and persisted ACP - sessions all agree on the usable workspace. Native Linux/macOS keeps the - original cwd unchanged. + editor workspaces as Windows drive paths (``E:\\Projects``) or + ``\\\\wsl.localhost\\`` UNC paths. Store and execute against the POSIX form so + agents, tools, and persisted ACP sessions all agree on the usable workspace. + Native Linux/macOS keeps the original cwd unchanged. """ - from hermes_constants import is_wsl + from hermes_constants import translate_cwd_for_wsl_backend - if not is_wsl(): - return cwd - translated = _win_path_to_wsl(str(cwd)) - return translated if translated is not None else cwd + return translate_cwd_for_wsl_backend(str(cwd)) def _normalize_cwd_for_compare(cwd: str | None) -> str: @@ -61,7 +48,9 @@ def _normalize_cwd_for_compare(cwd: str | None) -> str: # Normalize Windows drive paths into the equivalent WSL mount form so # ACP history filters match the same workspace across Windows and WSL. - translated = _win_path_to_wsl(expanded) + from hermes_constants import windows_path_to_wsl + + translated = windows_path_to_wsl(expanded) if translated is not None: expanded = translated elif re.match(r"^/mnt/[A-Za-z]/", expanded): diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index cbf7d15b3b86..1f272ab252dc 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import uuid from typing import Any, Dict, List, Optional @@ -14,6 +15,8 @@ from acp.schema import ( ToolKind, ) +logger = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Map hermes tool names -> ACP ToolKind # --------------------------------------------------------------------------- @@ -110,7 +113,12 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str: if tool_name == "web_extract": urls = args.get("urls", []) if urls: - return f"extract: {urls[0]}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "") + first = urls[0] + if isinstance(first, dict): + first = first.get("url") or first.get("href") or "?" + elif not isinstance(first, str): + first = "?" + return f"extract: {first}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "") return "web extract" if tool_name == "process": action = str(args.get("action") or "").strip() or "manage" @@ -1021,7 +1029,37 @@ def build_tool_start( *, edit_diff: Any = None, ) -> ToolCallStart: - """Create a ToolCallStart event for the given hermes tool invocation.""" + """Create a ToolCallStart event for the given hermes tool invocation. + + A malformed tool argument (e.g. a non-string ``command``/``path`` from a + model that ignores the schema) must never abort the ACP tool-call render — + ``build_tool_start`` runs on the live tool-progress callback and during + session history replay. On any failure in the title/content/location + builders, fall back to a minimal, valid start event. Mirrors + ``get_cute_tool_message`` in ``agent/display.py``, wrapped for the same + reason on the CLI side. + """ + try: + return _build_tool_start( + tool_call_id, tool_name, arguments, edit_diff=edit_diff + ) + except Exception as exc: # noqa: BLE001 — a tool-call render must never abort the turn + logger.debug("ACP tool-start render failed for %r: %s", tool_name, exc) + safe_name = tool_name if isinstance(tool_name, str) and tool_name else "tool" + return acp.start_tool_call( + tool_call_id, safe_name, kind=get_tool_kind(safe_name), + content=None, locations=[], raw_input=None, + ) + + +def _build_tool_start( + tool_call_id: str, + tool_name: str, + arguments: Dict[str, Any], + *, + edit_diff: Any = None, +) -> ToolCallStart: + """Build the ToolCallStart event (unguarded; see ``build_tool_start``).""" kind = get_tool_kind(tool_name) title = build_tool_title(tool_name, arguments) locations = extract_locations(arguments) diff --git a/agent/account_usage.py b/agent/account_usage.py index 712e57feda4a..9e48b0aac0cb 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -425,15 +425,28 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred ) -def _resolve_codex_usage_url(base_url: str) -> str: +def _codex_backend_urls(base_url: str) -> tuple[str, str, str]: + """Resolve the Codex backend endpoints (usage, reset-credits list, consume). + + Mirrors the Codex CLI's PathStyle split (codex-rs backend-client): base URLs + containing ``/backend-api`` use the ChatGPT ``/wham/...`` paths; everything + else uses ``/api/codex/...``. + """ normalized = (base_url or "").strip().rstrip("/") if not normalized: normalized = "https://chatgpt.com/backend-api/codex" if normalized.endswith("/codex"): normalized = normalized[: -len("/codex")] - if "/backend-api" in normalized: - return normalized + "/wham/usage" - return normalized + "/api/codex/usage" + prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex") + return ( + prefix + "/usage", + prefix + "/rate-limit-reset-credits", + prefix + "/rate-limit-reset-credits/consume", + ) + + +def _resolve_codex_usage_url(base_url: str) -> str: + return _codex_backend_urls(base_url)[0] def _resolve_codex_usage_credentials( @@ -525,6 +538,14 @@ def _fetch_codex_account_usage( ) ) details: list[str] = [] + reset_credits = payload.get("rate_limit_reset_credits") or {} + banked = reset_credits.get("available_count") + if isinstance(banked, (int, float)) and int(banked) > 0: + count = int(banked) + plural = "s" if count != 1 else "" + details.append( + f"You have {count} reset{plural} banked - use /usage reset to activate" + ) credits = payload.get("credits") or {} if credits.get("has_credits"): balance = credits.get("balance") @@ -542,6 +563,179 @@ def _fetch_codex_account_usage( ) +@dataclass(frozen=True) +class CodexResetRedeemResult: + """Outcome of a `/usage reset` attempt against the Codex backend.""" + + status: str # reset | nothing_to_reset | no_credit | already_redeemed | + # not_exhausted | no_credits_banked | unavailable + message: str + available_count: int = 0 + windows_reset: int = 0 + + @property + def redeemed(self) -> bool: + return self.status == "reset" + + +# Client-side guard threshold: a rate-limit window only counts as exhausted +# when it is fully used. Below this, redeeming a banked reset wastes most of +# its value, so we block and point at --force instead. +_CODEX_WINDOW_EXHAUSTED_PERCENT = 100.0 + + +def redeem_codex_reset_credit( + *, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + force: bool = False, +) -> CodexResetRedeemResult: + """Redeem one banked Codex rate-limit reset credit (`/usage reset`). + + Flow (mirrors the Codex CLI's reset-credits picker, codex-rs + ``backend-client``): + + 1. ``GET .../usage`` — read the current windows + banked credit count. + 2. Guard: zero banked credits → refuse. No window fully used and not + ``force`` → refuse with a warning (a banked reset restores the WHOLE + 5h + weekly allowance; burning it early wastes it). The backend has + the same protection (``nothing_to_reset`` doesn't consume the + credit), but failing fast client-side gives a clearer message. + 3. ``POST .../rate-limit-reset-credits/consume`` with a fresh UUID + idempotency key (``redeem_request_id``). No ``credit_id`` — the + backend picks the next available credit, exactly like the CLI's + default "Full reset" option. + + Never raises: every failure mode returns a ``CodexResetRedeemResult`` + with a user-renderable message. + """ + import uuid + + try: + token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key) + except Exception: + return CodexResetRedeemResult( + status="unavailable", + message="No Codex credentials available. Run `hermes auth` to sign in with your ChatGPT account.", + ) + usage_url, _credits_url, consume_url = _codex_backend_urls(resolved_base_url) + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "User-Agent": "codex-cli", + } + if account_id: + headers["ChatGPT-Account-Id"] = account_id + + try: + with httpx.Client(timeout=15.0) as client: + usage_resp = client.get(usage_url, headers=headers) + usage_resp.raise_for_status() + payload = usage_resp.json() or {} + + reset_credits = payload.get("rate_limit_reset_credits") or {} + raw_count = reset_credits.get("available_count") + available = int(raw_count) if isinstance(raw_count, (int, float)) else 0 + if available <= 0: + return CodexResetRedeemResult( + status="no_credits_banked", + message="No banked reset credits on this account — nothing to redeem.", + ) + + rate_limit = payload.get("rate_limit") or {} + worst_used: Optional[float] = None + for key in ("primary_window", "secondary_window"): + used = (rate_limit.get(key) or {}).get("used_percent") + if isinstance(used, (int, float)): + worst_used = max(worst_used or 0.0, float(used)) + exhausted = worst_used is not None and worst_used >= _CODEX_WINDOW_EXHAUSTED_PERCENT + if not exhausted and not force: + usage_note = ( + f"your busiest window is only {worst_used:.0f}% used" + if worst_used is not None + else "your current usage could not be confirmed as exhausted" + ) + plural = "s" if available != 1 else "" + return CodexResetRedeemResult( + status="not_exhausted", + message=( + f"⚠️ Not redeeming: {usage_note}. A banked reset restores your FULL " + f"5h + weekly limits, so spending it now would waste most of it. " + f"You have {available} reset{plural} banked. " + f"Use `/usage reset --force` to redeem anyway." + ), + available_count=available, + ) + + consume_resp = client.post( + consume_url, + headers={**headers, "Content-Type": "application/json"}, + json={"redeem_request_id": str(uuid.uuid4())}, + ) + consume_resp.raise_for_status() + body = consume_resp.json() or {} + except httpx.HTTPStatusError as exc: + code = exc.response.status_code + if code in (401, 403): + return CodexResetRedeemResult( + status="unavailable", + message=( + "Codex backend rejected the request (HTTP " + f"{code}). Reset credits require ChatGPT-account (OAuth) auth — " + "run `hermes auth` and sign in with your ChatGPT account." + ), + ) + return CodexResetRedeemResult( + status="unavailable", + message=f"Codex backend error (HTTP {code}) — try again shortly.", + ) + except Exception as exc: + return CodexResetRedeemResult( + status="unavailable", + message=f"Could not reach the Codex backend: {exc}", + ) + + code = str(body.get("code", "") or "").strip().lower() + windows_reset = body.get("windows_reset") + windows_reset = int(windows_reset) if isinstance(windows_reset, (int, float)) else 0 + remaining = max(0, available - 1) + plural = "s" if remaining != 1 else "" + if code == "reset": + return CodexResetRedeemResult( + status="reset", + message=( + f"✅ Reset redeemed — your usage limits have been reset. " + f"{remaining} banked reset{plural} remaining." + ), + available_count=remaining, + windows_reset=windows_reset, + ) + if code == "nothing_to_reset": + return CodexResetRedeemResult( + status="nothing_to_reset", + message=( + "Backend reports nothing to reset — your limits aren't exhausted. " + "The credit was NOT spent." + ), + available_count=available, + ) + if code == "no_credit": + return CodexResetRedeemResult( + status="no_credit", + message="Backend reports no available reset credit on this account.", + ) + if code == "already_redeemed": + return CodexResetRedeemResult( + status="already_redeemed", + message="This redemption was already processed — no additional credit was spent.", + available_count=remaining, + ) + return CodexResetRedeemResult( + status="unavailable", + message=f"Unexpected response from the Codex backend: {body!r}", + ) + + def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]: token = (resolve_anthropic_token() or "").strip() if not token: diff --git a/agent/agent_init.py b/agent/agent_init.py index 495260d21886..0c700c279b98 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -77,8 +77,8 @@ def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str: include the exact opt-back-out command. """ model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1] - # gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5 family is - # capped at 272K by the Codex OAuth backend. + # gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6 family + # is capped at 272K by the Codex OAuth backend. cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K" from_pct = int(round(autoraise["from"] * 100)) to_pct = int(round(autoraise["to"] * 100)) @@ -187,10 +187,26 @@ def _normalized_custom_base_url(value: Any) -> str: def _custom_provider_model_matches(agent_model: str, entry: Dict[str, Any]) -> bool: - provider_model = str(entry.get("model", "") or "").strip().lower() - if not provider_model: + agent_model_norm = str(agent_model or "").strip().lower() + # Multi-model entries (v12+ `providers..models` mapping / legacy + # `models:` list): the agent's model matching ANY catalog entry counts. + # Without this, a provider whose `model`/`default_model` differs from the + # session model silently fails to match and per-provider request settings + # (extra_body, e.g. OpenAI service_tier) are dropped — billing the whole + # session at the wrong tier (July 2026 sweeper incident: flex config + # ignored, ~2.3x overbilling). + models = entry.get("models") + catalog: List[str] = [] + if isinstance(models, dict): + catalog = [str(k).strip().lower() for k in models.keys()] + elif isinstance(models, (list, tuple)): + catalog = [str(m).strip().lower() for m in models] + if catalog and agent_model_norm in catalog: return True - return provider_model == str(agent_model or "").strip().lower() + provider_model = str(entry.get("model", "") or "").strip().lower() + if not provider_model and not catalog: + return True + return provider_model == agent_model_norm def _custom_provider_extra_body_for_agent( @@ -302,6 +318,7 @@ def init_agent( notice_callback: callable = None, notice_clear_callback: callable = None, event_callback: Optional[Callable[[str, dict], None]] = None, + reaction_callback: Optional[Callable[[str], None]] = None, max_tokens: int = None, reasoning_config: Dict[str, Any] = None, service_tier: str = None, @@ -411,13 +428,13 @@ def init_agent( agent.skip_context_files = skip_context_files agent.load_soul_identity = load_soul_identity agent.pass_session_id = pass_session_id - agent._credential_pool = credential_pool agent.log_prefix_chars = log_prefix_chars agent.log_prefix = f"{log_prefix} " if log_prefix else "" # Store effective base URL for feature detection (prompt caching, reasoning, etc.) agent.base_url = base_url or "" provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None agent.provider = provider_name or "" + agent._credential_pool = credential_pool agent.acp_command = acp_command or command agent.acp_args = list(acp_args or args or []) if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse", "codex_app_server"}: @@ -453,6 +470,24 @@ def init_agent( else: agent.api_mode = "chat_completions" + # Credential-pool validation runs AFTER provider auto-detection so + # a pool scoped to e.g. "anthropic" is not rejected when the agent + # was constructed with provider=None and an anthropic.com URL. + # Regression from #63048 which placed this check before the + # URL-based auto-detection block above (fixed #63425). + if credential_pool is not None: + try: + from agent.credential_pool import credential_pool_matches_provider + + if not credential_pool_matches_provider( + credential_pool, + agent.provider, + base_url=agent.base_url, + ): + agent._credential_pool = None + except Exception: + agent._credential_pool = None + # Eagerly warm the transport cache so import errors surface at init, # not mid-conversation. Also validates the api_mode is registered. try: @@ -535,6 +570,7 @@ def init_agent( agent.notice_callback = notice_callback agent.notice_clear_callback = notice_clear_callback agent.event_callback = event_callback + agent.reaction_callback = reaction_callback agent.tool_gen_callback = tool_gen_callback @@ -1281,6 +1317,14 @@ def init_agent( # SQLite session store (optional -- provided by CLI or gateway) agent._session_db = session_db agent._parent_session_id = parent_session_id + # A close flush and the worker's turn-start flush can overlap. The durable + # marker is attached to each in-memory message dict, so its test-and-append + # sequence must be serialized per agent rather than relying on SQLite alone. + agent._session_persist_lock = threading.RLock() + # CLI retains its just-accepted user dict until turn setup can reuse it. + # This preserves the message-local durable marker if close persistence wins + # the race before the agent's normal early turn flush. + agent._pending_cli_user_message = None agent._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes agent._session_db_created = False # DB row deferred to run_conversation() # Most agents own their session row and should finalize it on close(). diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 6f57f83e9774..c6ed459e93d9 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1208,7 +1208,42 @@ def restore_primary_runtime(agent) -> bool: api_mode=rt.get("compressor_api_mode", ""), ) - # ── Re-select from the credential pool if one is available ── + # ── Rebind and re-select the primary credential pool ── + # A cross-provider fallback attaches the fallback provider's pool. The + # runtime fields above restore the primary, but leaving that pool in + # place makes the next primary 401/429 hit the provider-mismatch guard + # and disables credential rotation. Reload the primary pool first; if + # auth storage is temporarily unreadable, clear the mismatched pool. + primary_provider = str(rt.get("provider") or "").strip().lower() + pool = getattr(agent, "_credential_pool", None) + pool_provider = str(getattr(pool, "provider", "") or "").strip().lower() + pool_matches_primary = pool_provider == primary_provider + if ( + primary_provider == "custom" + and pool_provider.startswith("custom:") + ): + try: + from agent.credential_pool import get_custom_provider_pool_key + + primary_key = ( + get_custom_provider_pool_key(str(rt.get("base_url") or "")) or "" + ).strip().lower() + pool_matches_primary = bool(primary_key) and primary_key == pool_provider + except Exception: + pool_matches_primary = False + if pool is not None and pool_provider and not pool_matches_primary: + agent._credential_pool = None + try: + from agent.credential_pool import load_pool + + agent._credential_pool = load_pool(primary_provider) + except Exception as exc: + logger.warning( + "Restore could not reload primary credential pool for %s: %s", + primary_provider, + exc, + ) + # The snapshot's api_key was captured at construction time. Across # turns the pool may have rotated (token revocation, billing/rate-limit # exhaustion, cooldown), leaving the snapshot key stale. Restoring it @@ -1222,7 +1257,6 @@ def restore_primary_runtime(agent) -> bool: entry = pool.select() if entry is not None: entry_provider = str(getattr(entry, "provider", "") or "").strip().lower() - primary_provider = str(rt.get("provider") or "").strip().lower() entry_matches_primary = entry_provider == primary_provider # Custom endpoints all carry the generic ``custom`` provider on # the agent while the pool entry is keyed ``custom:`` (see @@ -1271,6 +1305,13 @@ def restore_primary_runtime(agent) -> bool: primary_provider or "?", ) + # ── Restore reasoning_config if it was saved ── + # switch_model saves reasoning_config in _primary_runtime. If the + # snapshot predates that (older sessions), keep the current value. + saved_reasoning = rt.get("reasoning_config") + if saved_reasoning is not None: + agent.reasoning_config = dict(saved_reasoning) + # ── Reset fallback chain for the new turn ── agent._fallback_activated = False agent._fallback_index = 0 @@ -1564,6 +1605,17 @@ def anthropic_prompt_cache_policy( model_lower = eff_model.lower() provider_lower = eff_provider.lower() is_claude = "claude" in model_lower + # Kimi / Moonshot family via OpenRouter: same cache_control wire format + # as Claude on OpenRouter (envelope layout). Without this branch + # moonshotai/kimi-k2.6 falls through to (False, False), serving ~1% + # cache hits on 64K-token prompts and re-billing the full prompt on + # every turn. Observed within-turn progression with cache enabled: + # 1% → 67% → 84% → 97% (#25970). Reuses the canonical family matcher + # (covers bare k1./k2./k25 release slugs the substring check missed). + from agent.anthropic_adapter import _model_name_is_kimi_family + is_kimi = ( + _model_name_is_kimi_family(eff_model) or "moonshot" in model_lower + ) is_openrouter = base_url_host_matches(eff_base_url, "openrouter.ai") # Nous Portal proxies to OpenRouter behind the scenes — identical # OpenAI-wire envelope cache_control semantics. Treat it as an @@ -1577,7 +1629,7 @@ def anthropic_prompt_cache_policy( if is_native_anthropic: return True, True - if (is_openrouter or is_nous_portal) and is_claude: + if (is_openrouter or is_nous_portal) and (is_claude or is_kimi): return True, False # Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout # cache_control path as Portal Claude. Portal proxies to OpenRouter @@ -1805,13 +1857,30 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo # ── Swap core runtime fields ── agent.model = new_model agent.provider = new_provider - # Use new base_url when provided; only fall back to current when the - # new provider genuinely has no endpoint (e.g. native SDK providers). - # Without this guard the old provider's URL (e.g. Ollama's localhost - # address) would persist silently after switching to a cloud provider - # that returns an empty base_url string. + # Use the new base_url when provided. When it's empty AND the + # provider is actually changing, do NOT fall back to the current + # (old provider's) URL — that silently pairs the new provider label + # with the previous provider's endpoint (e.g. new_provider=minimax + # paired with the leftover api.githubcopilot.com URL), and every + # request after the switch 400s at the wrong host. This mismatched + # pair also gets snapshotted into _primary_runtime below, so it + # keeps re-applying on every subsequent turn until a full restart. + # Fail loud instead: the caller (model_switch.switch_model()) + # already resolves base_url for every real provider, so an empty + # value here means resolution failed upstream, not that the + # provider genuinely has none. Re-selecting the SAME provider with + # an empty base_url (e.g. a credential-only refresh) is still fine + # to keep the current URL. See #47828. + old_norm_provider = (old_provider or "").strip().lower() + new_norm_provider = (new_provider or "").strip().lower() if base_url: agent.base_url = base_url + elif old_norm_provider != new_norm_provider: + raise ValueError( + f"switch_model: no base_url resolved for provider " + f"'{new_provider}' (switching from '{old_provider}'); " + "refusing to keep the previous provider's endpoint" + ) agent.api_mode = api_mode # Invalidate transport cache — new api_mode may need a different transport if hasattr(agent, "_transport_cache"): @@ -1830,6 +1899,9 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo old_norm = (old_provider or "").strip().lower() new_norm = (new_provider or "").strip().lower() if old_norm != new_norm or getattr(agent, "_credential_pool", None) is None: + # A pool bound to the old provider is worse than no pool: the + # recovery guard rejects it and every later 401/429 skips rotation. + agent._credential_pool = None try: from agent.credential_pool import load_pool agent._credential_pool = load_pool(new_provider) @@ -1925,6 +1997,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo _sm_timeout = get_provider_request_timeout(agent.provider, agent.model) if _sm_timeout is not None: agent._client_kwargs["timeout"] = _sm_timeout + # Reapply provider-specific headers (e.g. OpenRouter HTTP-Referer, + # X-Title) that were lost when _client_kwargs was rebuilt from + # scratch. Without this, model switches clear attribution headers + # and OpenRouter logs show "Unknown" for subsequent requests. + agent._apply_client_headers_for_base_url(effective_base) agent.client = agent._create_openai_client( dict(agent._client_kwargs), reason="switch_model", @@ -1995,6 +2072,24 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo api_mode=agent.api_mode, ) + # ── Re-resolve reasoning_config from per-model override ── + # The new model may have a different reasoning_effort override. Re-read + # config so the override takes effect immediately on /model switch — + # resolved through the shared chokepoint (per-model > global; YAML + # boolean False = disabled). + try: + from hermes_constants import resolve_reasoning_config + from hermes_cli.config import load_config as _sm_load_config + + _reasoning_cfg = _sm_load_config() or {} + agent.reasoning_config = resolve_reasoning_config(_reasoning_cfg, agent.model) + logger.info( + "switch_model: reasoning_config resolved for %s: %s", + agent.model, agent.reasoning_config, + ) + except Exception as _reasoning_err: + logger.debug("switch_model: could not re-resolve reasoning_config: %s", _reasoning_err) + # ── Invalidate cached system prompt so it rebuilds next turn ── agent._cached_system_prompt = None @@ -2017,6 +2112,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo "client_kwargs": dict(agent._client_kwargs), "use_prompt_caching": agent._use_prompt_caching, "use_native_cache_layout": agent._use_native_cache_layout, + "reasoning_config": dict(agent.reasoning_config) if getattr(agent, "reasoning_config", None) else None, "compressor_model": getattr(_cc, "model", agent.model) if _cc else agent.model, "compressor_base_url": getattr(_cc, "base_url", agent.base_url) if _cc else agent.base_url, "compressor_api_key": getattr(_cc, "api_key", "") if _cc else "", diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 623560250df4..689d01010ad6 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -65,6 +65,7 @@ THINKING_BUDGET = {"xhigh": 32000, "high": 16000, "medium": 8000, "low": 4000} # maps to low on every model. See: # https://platform.claude.com/docs/en/about-claude/models/migration-guide ADAPTIVE_EFFORT_MAP = { + "ultra": "max", "max": "max", "xhigh": "xhigh", "high": "high", @@ -2102,7 +2103,7 @@ def _convert_user_message(content: Any) -> Dict[str, Any]: if isinstance(content, list): converted_blocks = _convert_content_to_anthropic(content) if not converted_blocks or all( - b.get("text", "").strip() == "" + (b.get("text") or "").strip() == "" for b in converted_blocks if isinstance(b, dict) and b.get("type") == "text" ): diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index d55f7df4fcdb..54c7e4678830 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -44,6 +44,7 @@ import contextlib import json import logging import os +import re import threading import time from pathlib import Path # noqa: F401 — used by test mocks @@ -102,7 +103,6 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance from agent.credential_pool import load_pool from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length -from agent.process_bootstrap import build_keepalive_http_client from hermes_cli.config import get_hermes_home from hermes_constants import OPENROUTER_BASE_URL from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars @@ -156,22 +156,47 @@ def _resolve_aux_verify(base_url: Optional[str]) -> Any: return True +_WARNED_KEEPALIVE_IMPORT_SKEW = False + + def _openai_http_client_kwargs( base_url: Optional[str], *, async_mode: bool = False, ) -> Dict[str, Any]: """Inject keepalive httpx client with env-only proxy (not macOS system proxy).""" - client = build_keepalive_http_client( - str(base_url or ""), - async_mode=async_mode, - verify=_resolve_aux_verify(base_url), - ) + try: + from agent.process_bootstrap import build_keepalive_http_client + client = build_keepalive_http_client( + str(base_url or ""), + async_mode=async_mode, + verify=_resolve_aux_verify(base_url), + ) + except (ImportError, AttributeError): + # Version-skewed installs (#64333): a process whose sys.path resolves + # an older agent/process_bootstrap.py without this helper — seen when + # the Desktop app's bundled runtime lags a git-installed source tree + # that newer callers (cron scheduler) were written against. Every cron + # job died on this ImportError before any agent logic ran. Degrade + # gracefully to the OpenAI SDK's default httpx client (respects macOS + # system proxy, no pool-level keepalive expiry) instead of failing the + # whole job, and say so once — silent version skew is how this bug + # went unnoticed until jobs were already dead on arrival. + global _WARNED_KEEPALIVE_IMPORT_SKEW + if not _WARNED_KEEPALIVE_IMPORT_SKEW: + _WARNED_KEEPALIVE_IMPORT_SKEW = True + logger.warning( + "agent.process_bootstrap.build_keepalive_http_client is " + "unavailable — mixed/stale install detected (#64333). Falling " + "back to the SDK default HTTP client. Run `hermes update` (or " + "reinstall the Desktop app) to resync the runtime." + ) + client = None + if client is None: return {} return {"http_client": client} - def _create_openai_client(*, api_key: str, base_url: str, **kwargs: Any) -> Any: kwargs = {**_openai_http_client_kwargs(base_url), **kwargs} # Hermes owns auxiliary retry + provider/model fallback policy (the @@ -314,15 +339,18 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool: return bare == "trinity-large-thinking" -# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.4/5.5. -# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the -# Codex backend hard-caps at 272K (verified live: a ~330K-token request to +# Context window enforced by ChatGPT's Codex OAuth backend for the +# gpt-5.4 / gpt-5.5 / gpt-5.6 families. The raw OpenAI API and OpenRouter +# expose 1.05M for the same slugs, but the Codex backend hard-caps at 272K +# (verified live for 5.4/5.5: a ~330K-token request to # chatgpt.com/backend-api/codex/responses is rejected with -# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the -# default 50% compaction trigger fires at ~136K — wasteful, since the model -# can hold far more raw context before summarization actually buys anything. -# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.4/ -# gpt-5.5 sessions use the window they actually have. +# ``context_length_exceeded`` while ~250K succeeds; gpt-5.6 shares the same +# 272K Codex cap — see _CODEX_OAUTH_CONTEXT_FALLBACK in model_metadata.py). +# With a 272K ceiling the default 50% compaction trigger fires at ~136K — +# wasteful, since the model can hold far more raw context before +# summarization actually buys anything. We raise the trigger to 85% (~231K) +# on this exact route so Codex gpt-5.4 / gpt-5.5 / gpt-5.6 sessions use the +# window they actually have. _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85 # gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a @@ -336,14 +364,16 @@ _CODEX_SPARK_COMPACTION_THRESHOLD = 0.70 def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool: - """True for gpt-5.4 / gpt-5.5 on the ChatGPT Codex OAuth backend. + """True for gpt-5.4 / gpt-5.5 / gpt-5.6 on the ChatGPT Codex OAuth backend. Matches only the Codex OAuth route (provider ``openai-codex``), not the direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a larger context window for the same slug and must keep the user's default - compaction threshold. ``gpt-5.4-pro`` / ``gpt-5.5-pro`` and dated snapshots - are matched via prefix so the override tracks both 272K-capped families - without re-listing every variant. + compaction threshold. ``-pro`` variants and dated snapshots are matched + via prefix so the override tracks every 272K-capped family (5.4, 5.5, + 5.6 sol/terra/luna incl. their ``-pro`` modes) without re-listing every + variant. (Name kept for backward compatibility with the + ``compression.codex_gpt55_autoraise`` config key.) """ prov = (provider or "").strip().lower() if prov != "openai-codex": @@ -356,6 +386,9 @@ def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = Non or bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.") + or bare == "gpt-5.6" + or bare.startswith("gpt-5.6-") + or bare.startswith("gpt-5.6.") ) @@ -410,11 +443,12 @@ def _compression_threshold_for_model( Per-model/route overrides: - Arcee Trinity Large Thinking → 0.75 (preserve reasoning context). - - gpt-5.4 / gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps - both families at 272K and the default 50% trigger would compact at - ~136K. Gated by ``allow_codex_gpt55_autoraise`` (historical config-key - name kept for backward compatibility) so the user can opt back down to - the global default (the caller passes the config flag through here). + - gpt-5.4 / gpt-5.5 / gpt-5.6 on the Codex OAuth route → 0.85, because + Codex caps all three families at 272K and the default 50% trigger + would compact at ~136K. Gated by ``allow_codex_gpt55_autoraise`` + (historical config-key name kept for backward compatibility) so the + user can opt back down to the global default (the caller passes the + config flag through here). - gpt-5.3-codex-spark on the Codex OAuth route → 0.70, because the model has a native 128K window and the default 50% trigger would compact at ~64K — wasting half the usable context. Not gated by the gpt-5.5 @@ -465,6 +499,10 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = { "kilocode": "google/gemini-3-flash-preview", "ollama-cloud": "nemotron-3-nano:30b", "tencent-tokenhub": "hy3-preview", + # NB: no "deepinfra" entry — its aux model lives on the ProviderProfile + # (plugins/model-providers/deepinfra: default_aux_model), which + # _get_aux_model_for_provider() reads first. Duplicating it here would be + # dead data that drifts when the profile's value is bumped. } # Legacy alias — callers that haven't been updated to _get_aux_model_for_provider() @@ -480,6 +518,33 @@ _PROVIDER_VISION_MODELS: Dict[str, str] = { "zai": "glm-5v-turbo", } + +def _resolve_provider_vision_default(provider: str) -> Optional[str]: + """Return the provider's preferred default vision model id, or None. + + Static entries in :data:`_PROVIDER_VISION_MODELS` win first (xiaomi / + zai have dedicated vision-only model names that don't live in any + discoverable catalog). Otherwise the provider's :class:`ProviderProfile` + gets a chance to supply one via its ``default_vision_model()`` hook — + that's where catalog-backed providers (DeepInfra) resolve a live default, + keeping the discovery logic inside their plugin instead of a name-check + branch here. + """ + static = _PROVIDER_VISION_MODELS.get(provider) + if static: + return static + try: + from providers import get_provider_profile + profile = get_provider_profile(provider) + except Exception: + return None + if profile is None: + return None + try: + return profile.default_vision_model() + except Exception: + return None + # Providers whose endpoint does not accept image input, even though the # provider's broader ecosystem has vision models available elsewhere. When # `auxiliary.vision.provider: auto` sees one of these as the main provider, @@ -863,6 +928,7 @@ class _CodexCompletionsAdapter: # `function_call_output` items with a valid call_id, so every # Responses path normalizes tool history identically and cannot drift. from agent.codex_responses_adapter import _chat_messages_to_responses_input + from utils import base_url_host_matches instructions = "You are a helpful assistant." replay_messages: List[Dict[str, Any]] = [] @@ -874,7 +940,18 @@ class _CodexCompletionsAdapter: else: replay_messages.append(msg) - input_items = _chat_messages_to_responses_input(replay_messages) + # Copilot (githubcopilot.com) binds replayed codex_message_items ids + # to a backend "connection" that doesn't survive credential + # rotation/gateway restarts — replaying one gets HTTP 401 "input + # item ID does not belong to this connection" (#32716). Auxiliary + # calls (context compression, flush_memories, MoA aggregation) go + # through this adapter instead of agent/transports/codex.py's + # build_kwargs, so they need the same guard applied independently. + _host_for_input = str(getattr(self._client, "base_url", "") or "") + _is_github_for_input = base_url_host_matches(_host_for_input, "githubcopilot.com") + input_items = _chat_messages_to_responses_input( + replay_messages, is_github_responses=_is_github_for_input, + ) resp_kwargs: Dict[str, Any] = { "model": model, @@ -1234,6 +1311,7 @@ class _AnthropicCompletionsAdapter: model = kwargs.get("model", self._model) tools = kwargs.get("tools") tool_choice = kwargs.get("tool_choice") + reasoning_config = kwargs.get("_reasoning_config") # ZAI's Anthropic-compatible endpoint rejects max_tokens on vision # models (glm-4v-flash etc.) with error code 1210. When the caller # signals this by setting _skip_zai_max_tokens in kwargs, omit it. @@ -1254,12 +1332,25 @@ class _AnthropicCompletionsAdapter: elif choice_type in {"auto", "required", "none"}: normalized_tool_choice = choice_type + # Reasoning priority: explicit per-call reasoning_config (MoA per-slot, + # passed as _reasoning_config by _build_call_kwargs) wins over an + # extra_body.reasoning dict (auxiliary..extra_body config). + # build_anthropic_kwargs translates the config dict into the native + # ``thinking`` field and handles models where thinking is mandatory. + _reasoning_cfg = reasoning_config + if _reasoning_cfg is None: + _eb = kwargs.get("extra_body") + if isinstance(_eb, dict): + _rc = _eb.get("reasoning") + if isinstance(_rc, dict): + _reasoning_cfg = _rc + anthropic_kwargs = build_anthropic_kwargs( model=model, messages=messages, tools=tools, max_tokens=max_tokens, - reasoning_config=None, + reasoning_config=_reasoning_cfg, tool_choice=normalized_tool_choice, is_oauth=self._is_oauth, ) @@ -1271,6 +1362,31 @@ class _AnthropicCompletionsAdapter: if not _forbids_sampling_params(model): anthropic_kwargs["temperature"] = temperature + # Pass through caller-supplied extra_body so providers behind + # Anthropic-compatible gateways receive their per-vendor request + # fields (thinking control, metadata, portal tags, ...). The dict + # form is the documented Anthropic SDK passthrough for non-standard + # request body keys; merge on top of whatever build_anthropic_kwargs + # already produced (e.g. fast-mode ``speed``) so call-time settings + # survive. Two exclusions: + # - ``reasoning``: the OpenAI-shaped config dict is TRANSLATED into + # the native ``thinking`` field above (build_anthropic_kwargs); + # forwarding the raw field alongside would double-specify + # reasoning and 400 on strict gateways. + # - ``_``-prefixed keys: private Hermes plumbing (_reasoning_config + # et al.), never wire fields. + caller_extra_body = kwargs.get("extra_body") + if caller_extra_body and isinstance(caller_extra_body, dict): + passthrough = { + k: v for k, v in caller_extra_body.items() + if k != "reasoning" and not str(k).startswith("_") + } + if passthrough: + existing = anthropic_kwargs.get("extra_body") or {} + if not isinstance(existing, dict): + existing = {} + anthropic_kwargs["extra_body"] = {**existing, **passthrough} + response = create_anthropic_message(self._client, anthropic_kwargs) _transport = get_transport("anthropic_messages") _nr = _transport.normalize_response( @@ -3377,6 +3493,7 @@ def _retry_same_provider_sync( tools: Optional[list], effective_timeout: float, effective_extra_body: dict, + reasoning_config: Optional[dict], ) -> Any: if task == "vision": _, retry_client, retry_model = resolve_vision_provider_client( @@ -3410,6 +3527,7 @@ def _retry_same_provider_sync( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=retry_base or resolved_base_url, ) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): @@ -3434,6 +3552,7 @@ async def _retry_same_provider_async( tools: Optional[list], effective_timeout: float, effective_extra_body: dict, + reasoning_config: Optional[dict], ) -> Any: if task == "vision": _, retry_client, retry_model = resolve_vision_provider_client( @@ -3467,6 +3586,7 @@ async def _retry_same_provider_async( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=retry_base or resolved_base_url, ) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): @@ -3574,6 +3694,40 @@ def _auth_refresh_provider_for_route( return normalized +def _fallback_entry_timeout(task: Optional[str], fb_label: str) -> Optional[float]: + """Resolve a per-entry ``timeout`` for a configured fallback candidate. + + A fallback candidate previously inherited the exact timeout the primary + provider was called with. When that deadline was tuned for the primary + (or the primary simply consumed its whole budget before failing over), + the fallback aborted on the same clock even when independently healthy — + a 163k-token compression that needs ~90s on the fallback died at the + primary's 30s deadline every turn (#62452). + + Entries in ``auxiliary..fallback_chain`` may declare their own + ``timeout`` (seconds). This helper reads it by parsing the entry index + out of the label minted by :func:`_try_configured_fallback_chain` + (``fallback_chain[]()`` — our own stable format). Returns + ``None`` when the label is not a configured-chain candidate, the entry + has no ``timeout``, or the value is invalid — callers then keep the + task-level timeout, preserving existing behavior. + """ + if not task or not fb_label: + return None + m = re.match(r"fallback_chain\[(\d+)\]", fb_label) + if not m: + return None + try: + chain = _get_auxiliary_task_config(task).get("fallback_chain") + entry = chain[int(m.group(1))] if isinstance(chain, list) else None + raw = entry.get("timeout") if isinstance(entry, dict) else None + except Exception: + return None + if isinstance(raw, (int, float)) and not isinstance(raw, bool) and raw > 0: + return float(raw) + return None + + def _call_fallback_candidate_sync( fb_client: Any, fb_model: Optional[str], @@ -3586,6 +3740,7 @@ def _call_fallback_candidate_sync( tools: Optional[list], effective_timeout: float, effective_extra_body: dict, + reasoning_config: Optional[dict], ) -> Optional[Any]: """Call one fallback candidate with stale-credential recovery. @@ -3601,13 +3756,27 @@ def _call_fallback_candidate_sync( once with a rebuilt client; if the retry also auth-fails (non-refreshable expired token), mark the provider unhealthy and return ``None`` so the caller can continue to the next fallback layer. Non-auth errors raise. + + ``effective_timeout`` is the task-level deadline; a configured-chain + candidate with its own ``timeout`` entry gets that instead, so a + fallback tuned differently from the primary is allowed its own budget + (#62452). """ + fb_timeout = _fallback_entry_timeout(task, fb_label) + if fb_timeout is not None and fb_timeout != effective_timeout: + logger.info( + "Auxiliary %s: %s using its configured timeout %.0fs " + "(task-level was %.0fs)", + task or "call", fb_label, fb_timeout, effective_timeout, + ) + effective_timeout = fb_timeout fb_base = str(getattr(fb_client, "base_url", "") or "") fb_kwargs = _build_call_kwargs( fb_label, fb_model, messages, temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, - extra_body=effective_extra_body, base_url=fb_base) + extra_body=effective_extra_body, reasoning_config=reasoning_config, + base_url=fb_base) try: return _validate_llm_response( fb_client.chat.completions.create(**fb_kwargs), task) @@ -3623,6 +3792,7 @@ def _call_fallback_candidate_sync( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=str(getattr(retry_client, "base_url", "") or fb_base)) try: return _validate_llm_response( @@ -3655,14 +3825,24 @@ async def _call_fallback_candidate_async( tools: Optional[list], effective_timeout: float, effective_extra_body: dict, + reasoning_config: Optional[dict], ) -> Optional[Any]: """Async mirror of :func:`_call_fallback_candidate_sync`.""" + fb_timeout = _fallback_entry_timeout(task, fb_label) + if fb_timeout is not None and fb_timeout != effective_timeout: + logger.info( + "Auxiliary %s: %s using its configured timeout %.0fs " + "(task-level was %.0fs)", + task or "call", fb_label, fb_timeout, effective_timeout, + ) + effective_timeout = fb_timeout fb_base = str(getattr(fb_client, "base_url", "") or "") fb_kwargs = _build_call_kwargs( fb_label, fb_model, messages, temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, - extra_body=effective_extra_body, base_url=fb_base) + extra_body=effective_extra_body, reasoning_config=reasoning_config, + base_url=fb_base) try: return _validate_llm_response( await fb_client.chat.completions.create(**fb_kwargs), task) @@ -3679,6 +3859,7 @@ async def _call_fallback_candidate_async( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=str(getattr(retry_client, "base_url", "") or fb_base)) try: return _validate_llm_response( @@ -4707,8 +4888,8 @@ def resolve_provider_client( if custom_entry is None: custom_entry = _get_named_custom_provider(provider) if custom_entry: - custom_base = custom_entry.get("base_url", "").strip() - custom_key = custom_entry.get("api_key", "").strip() + custom_base = (custom_entry.get("base_url") or "").strip() + custom_key = (custom_entry.get("api_key") or "").strip() custom_key_env = (custom_entry.get("key_env") or custom_entry.get("api_key_env") or "").strip() if not custom_key and custom_key_env: custom_key = os.getenv(custom_key_env, "").strip() @@ -5157,6 +5338,7 @@ def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Di _VISION_AUTO_PROVIDER_ORDER = ( "openrouter", "nous", + "deepinfra", ) @@ -5213,6 +5395,21 @@ def _resolve_strict_vision_backend( return resolve_provider_client("openai-codex", model, is_vision=True) if provider == "anthropic": return _try_anthropic() + if provider == "deepinfra": + # DeepInfra exposes vision-capable models (Llama-4 Scout/Maverick, + # Qwen3-VL, Gemma 3, Gemini) on the same OpenAI-compatible endpoint + # as its chat models. The default is discovered live via the profile's + # default_vision_model() hook (key-gated, chat-surface + vision tag) so + # we don't pin a hardcoded id that may rot when DeepInfra retires a + # model, and this module stays provider-agnostic. + vision_model = model or _resolve_provider_vision_default("deepinfra") + if not vision_model: + logger.debug( + "Vision auto-detect: deepinfra catalog unreachable or " + "returned no vision-tagged models — skipping" + ) + return None, None + return resolve_provider_client("deepinfra", vision_model, is_vision=True) if provider == "custom": return _try_custom_endpoint() return None, None @@ -5298,16 +5495,29 @@ def resolve_vision_provider_client( # _PROVIDER_VISION_MODELS provides per-provider vision model # overrides when the provider has a dedicated multimodal model # that differs from the chat model (e.g. xiaomi → mimo-v2-omni, - # zai → glm-5v-turbo). Nous is the exception: it has a dedicated - # strict vision backend with tier-aware defaults, so it must not - # fall through to the user's text chat model here. - # 2. OpenRouter (vision-capable aggregator fallback) + # zai → glm-5v-turbo). DeepInfra is similar but resolves its + # default vision model live from the catalog (see + # :func:`_resolve_provider_vision_default`). Nous is the + # exception: it has a dedicated strict vision backend with + # tier-aware defaults, so it must not fall through to the + # user's text chat model here. + # 2. OpenRouter (vision-capable aggregator fallback) # 3. Nous Portal (vision-capable aggregator fallback) - # 4. Stop + # 4. DeepInfra (OpenAI-compatible; vision model discovered + # live from the catalog — tried when + # DEEPINFRA_API_KEY is set) + # 5. Stop main_provider = _read_main_provider() main_model = _read_main_model() if main_provider and main_provider not in {"auto", ""}: - vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model) + # A provider-specific vision default wins over the user's chat model: + # static overrides (xiaomi/zai) and catalog-backed discovery (the + # DeepInfra profile hook) both yield a *known* vision-capable model, + # whereas the pinned chat model is usually NOT multimodal (e.g. the + # DeepSeek-V4-Flash default) and _main_model_supports_vision can't be + # trusted to catch that. Only fall back to the chat model when no + # provider default is available (catalog unreachable). + vision_model = _resolve_provider_vision_default(main_provider) or main_model if main_provider == "nous": sync_client, default_model = _resolve_strict_vision_backend( main_provider, vision_model @@ -6060,12 +6270,49 @@ def _effective_aux_timeout(task: str, timeout: Optional[float]) -> float: def _get_task_extra_body(task: str) -> Dict[str, Any]: - """Read auxiliary..extra_body and return a shallow copy when valid.""" + """Read auxiliary..extra_body and return a shallow copy when valid. + + Also folds in ``auxiliary..reasoning_effort`` as an + ``extra_body.reasoning`` config dict ({"enabled": ..., "effort": ...}) + when set. An explicit ``extra_body.reasoning`` in config wins over the + ``reasoning_effort`` shorthand (it is the more specific wire control). + Downstream, each wire already translates ``extra_body.reasoning``: + chat.completions passes it through, the Codex Responses adapter maps it + to top-level ``reasoning``/``include``, and the Anthropic auxiliary + client maps it to ``build_anthropic_kwargs(reasoning_config=...)``. + + MoA tasks are excluded by design: reasoning depth for MoA is a per-slot + setting in the MoA preset (``moa.presets..reference_models[]. + reasoning_effort`` / ``aggregator.reasoning_effort``), not an + auxiliary-task knob — an ensemble-wide value would override the + per-slot ones. + """ task_config = _get_auxiliary_task_config(task) raw = task_config.get("extra_body") - if isinstance(raw, dict): - return dict(raw) - return {} + result = dict(raw) if isinstance(raw, dict) else {} + if "reasoning" not in result: + effort = task_config.get("reasoning_effort") + if effort is not None and effort != "": + if task in ("moa_reference", "moa_aggregator"): + logger.warning( + "auxiliary.%s.reasoning_effort is not supported — MoA " + "reasoning depth is per-slot: set reasoning_effort on the " + "preset's reference_models entries / aggregator instead " + "(moa.presets....). Ignoring.", + task, + ) + return result + from hermes_constants import parse_reasoning_effort + parsed = parse_reasoning_effort(effort) + if parsed is not None: + result["reasoning"] = parsed + else: + logger.warning( + "auxiliary.%s.reasoning_effort %r is not a valid level " + "(none, minimal, low, medium, high, xhigh, max, ultra) — ignoring", + task, effort, + ) + return result # --------------------------------------------------------------------------- @@ -6173,6 +6420,32 @@ def _convert_openai_images_to_anthropic(messages: list) -> list: return converted +_PROFILE_REASONING_KEYS = { + "reasoning", + "reasoning_effort", + "thinking", + "thinking_config", + "thinkingconfig", + "thinking_budget", + "thinkingbudget", + "enable_thinking", + "think", + "verbosity", +} + + +def _contains_profile_reasoning_fields(value: Any) -> bool: + """Return whether a profile payload contains a reasoning wire control.""" + if not isinstance(value, dict): + return False + for key, nested in value.items(): + normalized = str(key).strip().lower() + if normalized in _PROFILE_REASONING_KEYS: + return True + if _contains_profile_reasoning_fields(nested): + return True + return False + def _build_call_kwargs( provider: str, @@ -6183,6 +6456,7 @@ def _build_call_kwargs( tools: Optional[list] = None, timeout: float = 30.0, extra_body: Optional[dict] = None, + reasoning_config: Optional[dict] = None, base_url: Optional[str] = None, ) -> dict: """Build kwargs for .chat.completions.create() with model/provider adjustments.""" @@ -6266,13 +6540,89 @@ def _build_call_kwargs( _deduped.append(_t) kwargs["tools"] = _deduped - # Provider-specific extra_body + # Build provider-aware reasoning kwargs through the same profile hooks used + # by the standard chat-completions transport. Some providers require + # top-level controls (Kimi/custom ``reasoning_effort``), others use nested + # body fields (Gemini ``thinking_config``), and OpenRouter/Nous use + # ``extra_body.reasoning``. Profiles are the source of truth for those wire + # shapes. Providers without a reasoning-aware profile retain the generic + # ``extra_body.reasoning`` fallback used by Codex-compatible adapters. + effective_base = base_url or ( + _current_custom_base_url() if provider == "custom" else "" + ) + profile_body: Dict[str, Any] = {} + profile_reasoning_extra: Dict[str, Any] = {} + profile_top_level: Dict[str, Any] = {} + profile_handles_reasoning = False + try: + from providers import get_provider_profile + from providers.base import ProviderProfile + + profile = get_provider_profile(str(provider or "").strip().lower()) + if profile is not None: + profile_body = profile.build_extra_body( + model=model, + base_url=effective_base, + reasoning_config=reasoning_config, + ) or {} + profile_reasoning_extra, profile_top_level = ( + profile.build_api_kwargs_extras( + reasoning_config=reasoning_config, + supports_reasoning=reasoning_config is not None, + model=model, + base_url=effective_base, + ) + ) + profile_reasoning_extra = profile_reasoning_extra or {} + profile_top_level = profile_top_level or {} + profile_handles_reasoning = ( + type(profile).build_api_kwargs_extras + is not ProviderProfile.build_api_kwargs_extras + or _contains_profile_reasoning_fields(profile_body) + or _contains_profile_reasoning_fields(profile_reasoning_extra) + or _contains_profile_reasoning_fields(profile_top_level) + ) + except Exception as exc: + logger.debug( + "_build_call_kwargs: provider profile projection failed for %s: %s", + provider, + exc, + ) + + kwargs.update(profile_top_level) merged_extra = dict(extra_body or {}) - if provider == "nous": - merged_extra.setdefault("tags", []).extend(_nous_portal_tags()) + merged_extra.update(profile_body) + merged_extra.update(profile_reasoning_extra) + if ( + reasoning_config + and isinstance(reasoning_config, dict) + and not profile_handles_reasoning + ): + if reasoning_config.get("enabled") is False: + merged_extra["reasoning"] = {"enabled": False} + else: + effort = reasoning_config.get("effort") or "medium" + merged_extra["reasoning"] = {"enabled": True, "effort": effort} + if provider == "nous" and "tags" not in merged_extra: + merged_extra["tags"] = _nous_portal_tags() if merged_extra: kwargs["extra_body"] = merged_extra + # Native Anthropic Messages adapters do not consume ``extra_body``. Carry + # the normalized Hermes reasoning config through a private kwarg so the + # adapter can pass it into build_anthropic_kwargs(), where provider-aware + # thinking/output_config projection lives. Do not expose this private kwarg + # to ordinary OpenAI-compatible SDK clients, which would reject it. + if reasoning_config and isinstance(reasoning_config, dict): + provider_norm = str(provider or "").strip().lower() + effective_base = base_url or "" + if ( + provider_norm == "anthropic" + or _endpoint_speaks_anthropic_messages(effective_base) + or _is_anthropic_compat_endpoint(provider_norm, effective_base) + ): + kwargs["_reasoning_config"] = dict(reasoning_config) + return kwargs @@ -6382,6 +6732,7 @@ def call_llm( tools: list = None, timeout: float = None, extra_body: dict = None, + reasoning_config: Optional[dict] = None, api_mode: str = None, stream: bool = False, stream_options: dict = None, @@ -6405,6 +6756,8 @@ def call_llm( tools: Tool definitions (for function calling). timeout: Request timeout in seconds (None = read from auxiliary.{task}.timeout config). extra_body: Additional request body fields. + reasoning_config: Optional Hermes reasoning config for direct model calls + such as MoA reference/aggregator slots. stream: When True, return the raw SDK streaming iterator instead of a validated complete response. The caller is responsible for consuming chunks (and for any fallback). Used by the MoA aggregator so its @@ -6509,6 +6862,7 @@ def call_llm( resolved_provider, final_model, messages, temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=_base_info or resolved_base_url) # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) @@ -6761,6 +7115,7 @@ def call_llm( tools=tools, effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config, ) # ── Same-provider credential-pool recovery ───────────────────── @@ -6803,6 +7158,7 @@ def call_llm( tools=tools, effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config, ) except Exception as retry2_err: # The rotated key also hit a quota/auth wall. Mark it @@ -6924,7 +7280,8 @@ def call_llm( task=task, messages=messages, temperature=temperature, max_tokens=max_tokens, tools=tools, effective_timeout=effective_timeout, - effective_extra_body=effective_extra_body) + effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config) if fb_resp is not None: return fb_resp # The candidate had a stale/unrefreshable credential and was @@ -6938,7 +7295,8 @@ def call_llm( task=task, messages=messages, temperature=temperature, max_tokens=max_tokens, tools=tools, effective_timeout=effective_timeout, - effective_extra_body=effective_extra_body) + effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config) if fb_resp is not None: return fb_resp # All fallback layers exhausted — emit a single user-visible @@ -7033,6 +7391,7 @@ async def async_call_llm( tools: list = None, timeout: float = None, extra_body: dict = None, + reasoning_config: Optional[dict] = None, ) -> Any: """Centralized asynchronous LLM call. @@ -7112,6 +7471,7 @@ async def async_call_llm( resolved_provider, final_model, messages, temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, + reasoning_config=reasoning_config, base_url=_client_base or resolved_base_url) # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) @@ -7308,6 +7668,7 @@ async def async_call_llm( tools=tools, effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config, ) # ── Same-provider credential-pool recovery (mirrors sync) ───── @@ -7345,6 +7706,7 @@ async def async_call_llm( tools=tools, effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config, ) except Exception as retry2_err: if (_is_payment_error(retry2_err) or _is_auth_error(retry2_err) @@ -7433,7 +7795,8 @@ async def async_call_llm( task=task, messages=messages, temperature=temperature, max_tokens=max_tokens, tools=tools, effective_timeout=effective_timeout, - effective_extra_body=effective_extra_body) + effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config) if fb_resp is not None: return fb_resp # Stale/unrefreshable candidate credential — quarantined; walk @@ -7449,7 +7812,8 @@ async def async_call_llm( task=task, messages=messages, temperature=temperature, max_tokens=max_tokens, tools=tools, effective_timeout=effective_timeout, - effective_extra_body=effective_extra_body) + effective_extra_body=effective_extra_body, + reasoning_config=reasoning_config) if fb_resp is not None: return fb_resp # All fallback layers exhausted — warn before re-raising. (#26882) diff --git a/agent/background_review.py b/agent/background_review.py index 3f4e5efcd376..c2ea87bd94e2 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -62,6 +62,11 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]: "api_key": parent_runtime.get("api_key") or None, "base_url": parent_runtime.get("base_url") or None, "api_mode": parent_api_mode, + "credential_pool": getattr(agent, "_credential_pool", None), + "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), + "max_tokens": getattr(agent, "max_tokens", None), + "command": getattr(agent, "acp_command", None), + "args": list(getattr(agent, "acp_args", []) or []), "routed": False, } try: @@ -89,10 +94,15 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]: ) return { "provider": rp.get("provider") or task_provider, - "model": task_model, + "model": rp.get("model") or task_model, "api_key": rp.get("api_key"), "base_url": rp.get("base_url"), "api_mode": rp.get("api_mode"), + "credential_pool": rp.get("credential_pool"), + "request_overrides": dict(rp.get("request_overrides") or {}), + "max_tokens": rp.get("max_output_tokens"), + "command": rp.get("command"), + "args": list(rp.get("args") or []), "routed": True, } except Exception as e: @@ -680,6 +690,25 @@ def _run_review_in_thread( # Match parent's toolset config so ``tools[]`` is byte-identical # in the request body — Anthropic's cache key includes it. # (The runtime whitelist below still restricts dispatch.) + _fork_kwargs: Dict[str, Any] = {} + if isinstance(_rt.get("max_tokens"), int): + _fork_kwargs["max_tokens"] = _rt["max_tokens"] + if isinstance(_rt.get("command"), str) and _rt["command"]: + _fork_kwargs["acp_command"] = _rt["command"] + _fork_kwargs["acp_args"] = _rt.get("args") or [] + # Match parent's reasoning config so the fork's ``thinking`` / + # ``output_config`` are byte-identical in the request body — + # Anthropic's cache key is namespaced by ``thinking`` presence. + # Same-model path only: when routed to a different aux model the + # cache is cold regardless (parity buys nothing) and the parent's + # effort vocabulary may not be valid for the routed model/provider + # (e.g. OpenRouter ``extra_body.reasoning.effort`` is forwarded + # unclamped; codex_responses passes ``max``/``ultra`` through + # unmapped except on gpt-5.6/xAI). Let the routed fork use + # provider defaults — matching the ``not _routed`` gate on + # _cached_system_prompt below. + if not _routed: + _fork_kwargs["reasoning_config"] = getattr(agent, "reasoning_config", None) review_agent = AIAgent( model=_rt.get("model") or agent.model, max_iterations=16, @@ -689,11 +718,13 @@ def _run_review_in_thread( api_mode=_rt.get("api_mode"), base_url=_rt.get("base_url") or None, api_key=_rt.get("api_key") or None, - credential_pool=getattr(agent, "_credential_pool", None), + credential_pool=_rt.get("credential_pool"), + request_overrides=_rt.get("request_overrides") or {}, parent_session_id=agent.session_id, enabled_toolsets=getattr(agent, "enabled_toolsets", None), disabled_toolsets=getattr(agent, "disabled_toolsets", None), skip_memory=True, + **_fork_kwargs, ) review_agent._memory_write_origin = "background_review" review_agent._memory_write_context = "background_review" diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index d5dab7bafff2..51d0afbe3cbe 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -528,10 +528,19 @@ def _convert_content_to_converse(content) -> List[Dict]: mime_part = header[5:].split(";")[0] if mime_part: media_type = mime_part + # Decode base64 to raw bytes — boto3 re-encodes at the + # wire layer, so passing the base64 string directly + # results in double-encoding and Bedrock rejects it with + # "Failed to sanitize image". Ref: #33317. + import base64 + try: + raw_bytes = base64.b64decode(data) + except Exception: + raw_bytes = data.encode("utf-8") blocks.append({ "image": { "format": media_type.split("/")[-1] if "/" in media_type else "jpeg", - "source": {"bytes": data}, + "source": {"bytes": raw_bytes}, } }) else: diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 9be0a7fed52e..45a88f8decf0 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -28,6 +28,7 @@ from typing import Any, Dict, Optional from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH from agent.error_classifier import FailoverReason +from agent.errors import EmptyStreamError from agent.gemini_native_adapter import is_native_gemini_base_url from agent.model_metadata import is_local_endpoint from agent.message_sanitization import ( @@ -164,6 +165,25 @@ def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]: return None +def _provider_preferences_for_agent(agent) -> Dict[str, Any]: + """Build the validated provider-routing object shared by request paths.""" + preferences: Dict[str, Any] = {} + if agent.providers_allowed: + preferences["only"] = agent.providers_allowed + if agent.providers_ignored: + preferences["ignore"] = agent.providers_ignored + if agent.providers_order: + preferences["order"] = agent.providers_order + provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) + if provider_sort: + preferences["sort"] = provider_sort + if agent.provider_require_parameters: + preferences["require_parameters"] = True + if agent.provider_data_collection: + preferences["data_collection"] = agent.provider_data_collection + return preferences + + def _env_float(name: str, default: float) -> float: try: return float(os.getenv(name, str(default))) @@ -217,6 +237,129 @@ def _check_stale_giveup(agent) -> None: ) +def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client): + """Run one non-streaming LLM request for the active api_mode and return it. + + Shared by the interrupt-worker path (``interruptible_api_call``) and the + inline path (``direct_api_call``) so the per-api_mode dispatch — codex / + anthropic / bedrock / MoA / OpenAI-compatible — lives in exactly one place. + + ``make_client(reason)`` builds the per-request OpenAI client for the codex + and OpenAI-compatible branches; the worker path uses it to register the + client with its stranger-thread abort machinery, the inline path uses it to + capture the client for its own ``finally`` close. The anthropic / bedrock / + MoA branches manage their own clients and never call it. All interrupt, + abort, cancellation, and close semantics stay in the callers — this helper + only issues the request. + """ + if agent.api_mode == "codex_responses": + request_client = make_client("codex_stream_request") + return agent._run_codex_stream( + api_kwargs, + client=request_client, + on_first_delta=getattr(agent, "_codex_on_first_delta", None), + ) + if agent.api_mode == "anthropic_messages": + return agent._anthropic_messages_create(api_kwargs) + if agent.api_mode == "bedrock_converse": + # Bedrock uses boto3 directly — no OpenAI client needed. + # normalize_converse_response produces an OpenAI-compatible + # SimpleNamespace so the rest of the agent loop can treat + # bedrock responses like chat_completions responses. + from agent.bedrock_adapter import ( + _get_bedrock_runtime_client, + invalidate_runtime_client, + is_stale_connection_error, + normalize_converse_response, + ) + region = api_kwargs.pop("__bedrock_region__", "us-east-1") + api_kwargs.pop("__bedrock_converse__", None) + client = _get_bedrock_runtime_client(region) + try: + raw_response = client.converse(**api_kwargs) + except Exception as _bedrock_exc: + # Evict the cached client on stale-connection failures + # so the outer retry loop builds a fresh client/pool. + if is_stale_connection_error(_bedrock_exc): + invalidate_runtime_client(region) + raise + return normalize_converse_response(raw_response) + if agent.provider == "moa": + # MoA is a virtual chat-completions provider backed by the + # in-process MoAClient facade. Do not rebuild a request-local + # OpenAI client from the virtual runtime metadata. + return agent.client.chat.completions.create(**api_kwargs) + request_client = make_client("chat_completion_request") + return request_client.chat.completions.create(**api_kwargs) + + +def should_use_direct_api_call(agent) -> bool: + """Whether a cron OpenAI-wire request should skip the interrupt worker. + + Issue #62151 is specific to OpenRouter's chat-completions path inside the + gateway cron thread stack. Keep native/Codex/Bedrock/MoA transports on their + established workers: their cancellation and client ownership differ, and + the report provides no evidence that those paths share the pre-HTTP wedge. + """ + return ( + getattr(agent, "platform", None) == "cron" + and getattr(agent, "api_mode", None) == "chat_completions" + and getattr(agent, "provider", None) != "moa" + ) + + +def direct_api_call(agent, api_kwargs: dict): + """Run a non-streaming LLM call inline on the conversation thread. + + Used when ``should_use_direct_api_call`` is True. Skips the interrupt worker + (whose only job is interactive-interrupt responsiveness, which this context + does not have) so the nested-pool deadlock (#62151) cannot occur. Because the + request runs in-flight normally, the per-request OpenAI client's own httpx + timeout (provider ``request_timeout_seconds`` / ``HERMES_API_TIMEOUT``) bounds + a genuinely hung provider — the same bound interactive calls already rely on. + """ + _check_stale_giveup(agent) + agent._touch_activity("waiting for non-streaming API response") + request_client_holder = {"client": None} + request_client_lock = threading.Lock() + + def _abort_active_request(reason: str) -> None: + """Abort the inline request from cron's watchdog/interrupt thread.""" + with request_client_lock: + request_client = request_client_holder["client"] + if request_client is not None: + agent._abort_request_openai_client(request_client, reason=reason) + + def _make_client(reason: str): + client = agent._create_request_openai_client(reason=reason, api_kwargs=api_kwargs) + with request_client_lock: + request_client_holder["client"] = client + agent._active_request_abort = _abort_active_request + return client + + try: + response = _dispatch_nonstreaming_api_request( + agent, api_kwargs, make_client=_make_client + ) + except Exception: + if getattr(agent, "_interrupt_requested", False): + raise InterruptedError("Agent interrupted during API call") from None + raise + else: + if getattr(agent, "_interrupt_requested", False): + raise InterruptedError("Agent interrupted during API call") + _reset_stale_streak(agent) + return response + finally: + if getattr(agent, "_active_request_abort", None) is _abort_active_request: + agent._active_request_abort = None + with request_client_lock: + request_client = request_client_holder["client"] + request_client_holder["client"] = None + if request_client is not None: + agent._close_request_openai_client(request_client, reason="request_complete") + + def interruptible_api_call(agent, api_kwargs: dict): """ Run the API call in a background thread so the main conversation loop @@ -231,6 +374,12 @@ def interruptible_api_call(agent, api_kwargs: dict): the main retry loop can try again with backoff / credential rotation / provider fallback. """ + # Cron and other non-interactive, nested-pool contexts must not spawn the + # interrupt worker — it wedges before the socket opens on the 2nd+ call + # (#62151). Run inline instead. See should_use_direct_api_call. + if should_use_direct_api_call(agent): + return direct_api_call(agent, api_kwargs) + result = {"response": None, "error": None} # Cross-turn stale-call circuit breaker (#58962) — non-streaming sibling @@ -294,56 +443,19 @@ def interruptible_api_call(agent, api_kwargs: dict): def _call(): try: - if agent.api_mode == "codex_responses": - request_client = _set_request_client( + # _set_request_client registers each per-request OpenAI client with + # the stranger-thread abort machinery above; the shared dispatch + # helper builds it via this callback so the interrupt / stale-call + # detectors can force-close the worker's connection. + result["response"] = _dispatch_nonstreaming_api_request( + agent, + api_kwargs, + make_client=lambda reason: _set_request_client( agent._create_request_openai_client( - reason="codex_stream_request", - api_kwargs=api_kwargs, + reason=reason, api_kwargs=api_kwargs ) - ) - result["response"] = agent._run_codex_stream( - api_kwargs, - client=request_client, - on_first_delta=getattr(agent, "_codex_on_first_delta", None), - ) - elif agent.api_mode == "anthropic_messages": - result["response"] = agent._anthropic_messages_create(api_kwargs) - elif agent.api_mode == "bedrock_converse": - # Bedrock uses boto3 directly — no OpenAI client needed. - # normalize_converse_response produces an OpenAI-compatible - # SimpleNamespace so the rest of the agent loop can treat - # bedrock responses like chat_completions responses. - from agent.bedrock_adapter import ( - _get_bedrock_runtime_client, - invalidate_runtime_client, - is_stale_connection_error, - normalize_converse_response, - ) - region = api_kwargs.pop("__bedrock_region__", "us-east-1") - api_kwargs.pop("__bedrock_converse__", None) - client = _get_bedrock_runtime_client(region) - try: - raw_response = client.converse(**api_kwargs) - except Exception as _bedrock_exc: - # Evict the cached client on stale-connection failures - # so the outer retry loop builds a fresh client/pool. - if is_stale_connection_error(_bedrock_exc): - invalidate_runtime_client(region) - raise - result["response"] = normalize_converse_response(raw_response) - elif agent.provider == "moa": - # MoA is a virtual chat-completions provider backed by the - # in-process MoAClient facade. Do not rebuild a request-local - # OpenAI client from the virtual runtime metadata. - result["response"] = agent.client.chat.completions.create(**api_kwargs) - else: - request_client = _set_request_client( - agent._create_request_openai_client( - reason="chat_completion_request", - api_kwargs=api_kwargs, - ) - ) - result["response"] = request_client.chat.completions.create(**api_kwargs) + ), + ) except Exception as e: # If the request was cancelled by the main thread's interrupt # handler, the transport error is the expected consequence of our @@ -392,6 +504,29 @@ def interruptible_api_call(agent, api_kwargs: dict): if _codex_floor: _stale_timeout = max(_stale_timeout, _codex_floor) + # ── Codex absolute hard ceiling (#64507) ────────────────────────── + # ``openai_codex_stale_timeout_floor`` *raises* the stale timeout (up to + # 1200s at >100k tokens) so healthy gateway-scale payloads aren't aborted. + # The scaled no-byte TTFB watchdog catches dead streams that never emit a + # first byte, but a request that emits SOME bytes and then wedges (the + # issue-64507 symptom: vision-inflated request, worker idle, no ended_at) + # is only reclaimed at the (high) stale floor. Add a flat, finite hard + # ceiling on total request time that ALWAYS applies to openai-codex + # requests regardless of the TTFB/stale interaction, so a stalled request + # is recovered (retry loop / visible failure) instead of hanging + # indefinitely. The default sits ABOVE the maximum stale floor (1200s) so + # it never clamps an intentionally-raised timeout for healthy large + # requests — it is a backstop against unbounded growth, not a tighter + # limit. Tunable via HERMES_CODEX_HARD_TIMEOUT_SECONDS (set to 0 to + # disable the ceiling entirely; that restores the pre-fix behavior). + _codex_hard_timeout = _env_float("HERMES_CODEX_HARD_TIMEOUT_SECONDS", 1500.0) + if ( + _codex_watchdog_enabled + and _openai_codex_backend + and _codex_hard_timeout > 0 + ): + _stale_timeout = min(_stale_timeout, _codex_hard_timeout) + if _est_tokens_for_codex_watchdog > 100_000: _codex_idle_timeout_default = 180.0 elif _est_tokens_for_codex_watchdog > 50_000: @@ -422,25 +557,28 @@ def interruptible_api_call(agent, api_kwargs: dict): and _ttfb_disable_above > 0 and _est_tokens_for_codex_watchdog >= _ttfb_disable_above ): - _ttfb_enabled = False - logger.info( - "Disabling openai-codex no-byte TTFB watchdog for large request " - "(context=~%s tokens >= %.0f). Waiting for backend response instead. " - "Set HERMES_CODEX_TTFB_STRICT=1 to force early reconnects.", - f"{_est_tokens_for_codex_watchdog:,}", - _ttfb_disable_above, - ) - else: - _ttfb_cap = _env_float("HERMES_CODEX_TTFB_MAX_SECONDS", 120.0) - if _ttfb_cap > 0 and _ttfb_timeout > _ttfb_cap: + _large_request_ttfb_timeout = _codex_idle_timeout_default + if _ttfb_timeout < _large_request_ttfb_timeout: logger.info( - "Capping openai-codex no-byte TTFB timeout from %.0fs to %.0fs " - "(context=~%s tokens). Set HERMES_CODEX_TTFB_MAX_SECONDS to tune.", + "Scaling openai-codex no-byte TTFB watchdog from %.0fs to %.0fs " + "for large request (context=~%s tokens >= %.0f). " + "Set HERMES_CODEX_TTFB_STRICT=1 to keep the smaller cutoff.", _ttfb_timeout, - _ttfb_cap, + _large_request_ttfb_timeout, f"{_est_tokens_for_codex_watchdog:,}", + _ttfb_disable_above, ) - _ttfb_timeout = _ttfb_cap + _ttfb_timeout = _large_request_ttfb_timeout + _ttfb_cap = _env_float("HERMES_CODEX_TTFB_MAX_SECONDS", 120.0) + if _ttfb_cap > 0 and _ttfb_timeout > _ttfb_cap: + logger.info( + "Capping openai-codex no-byte TTFB timeout from %.0fs to %.0fs " + "(context=~%s tokens). Set HERMES_CODEX_TTFB_MAX_SECONDS to tune.", + _ttfb_timeout, + _ttfb_cap, + f"{_est_tokens_for_codex_watchdog:,}", + ) + _ttfb_timeout = _ttfb_cap _codex_idle_enabled = _codex_watchdog_enabled _codex_idle_timeout = _env_float( @@ -466,12 +604,23 @@ def interruptible_api_call(agent, api_kwargs: dict): t.join(timeout=0.3) _poll_count += 1 - # Touch activity every ~30s so the gateway's inactivity - # monitor knows we're alive while waiting for the response. + # Every ~30s: touch activity for the gateway inactivity monitor AND + # rewrite the live spinner/status line so CLI/TUI/Desktop users see + # what the agent is waiting on instead of an unexplained generic + # spinner (the "infinite thinking" complaint — the wait itself is + # usually a slow/overloaded provider, but the UI never said so). if _poll_count % 100 == 0: # 100 × 0.3s = 30s _elapsed = time.time() - _call_start - agent._touch_activity( - f"waiting for non-streaming response ({int(_elapsed)}s elapsed)" + _deadline = _stale_timeout + if ( + _ttfb_enabled + and getattr(agent, "_codex_stream_last_event_ts", None) is None + ): + _deadline = min(_deadline, _ttfb_timeout) + agent._emit_wait_notice( + f"⏳ waiting on {api_kwargs.get('model', 'the provider')} — " + f"{int(_elapsed)}s with no response yet (provider may be slow " + f"or overloaded; auto-reconnect at {int(_deadline)}s)" ) _elapsed = time.time() - _call_start @@ -516,6 +665,10 @@ def interruptible_api_call(agent, api_kwargs: dict): _close_request_client_once("codex_ttfb_kill") except Exception: pass + agent._emit_wait_notice( + f"⚠ no response from provider in {int(_elapsed)}s — " + f"reconnecting..." + ) agent._touch_activity( f"codex stream killed after {int(_elapsed)}s with no first byte" ) @@ -801,21 +954,8 @@ def build_api_kwargs(agent, api_messages: list) -> dict: _omit_temp = False _fixed_temp = None - # Provider preferences (OpenRouter-style) - _prefs: Dict[str, Any] = {} - if agent.providers_allowed: - _prefs["only"] = agent.providers_allowed - if agent.providers_ignored: - _prefs["ignore"] = agent.providers_ignored - if agent.providers_order: - _prefs["order"] = agent.providers_order - _provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) - if _provider_sort: - _prefs["sort"] = _provider_sort - if agent.provider_require_parameters: - _prefs["require_parameters"] = True - if agent.provider_data_collection: - _prefs["data_collection"] = agent.provider_data_collection + # Provider preferences (aggregator profile decides whether to emit them). + _prefs = _provider_preferences_for_agent(agent) # Anthropic-compatible max-output fallback (last resort only — applied in # build_kwargs *after* ephemeral/user/profile max_tokens, never overriding @@ -1399,6 +1539,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool fb_api_mode = "bedrock_converse" old_model = agent.model + old_provider = agent.provider # Clear the per-config context_length override so the fallback # model's actual context window is resolved instead of inheriting @@ -1536,6 +1677,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool api_mode=agent.api_mode, ) + # Re-resolve reasoning_config for the new fallback model (Closes #21256). + # Shared chokepoint: per-model override > global reasoning_effort + # (YAML boolean False = disabled). Wrapped in try/except because a + # config load failure must not kill the swap. + try: + from hermes_cli.config import load_config + from hermes_constants import resolve_reasoning_config + + agent.reasoning_config = resolve_reasoning_config( + load_config() or {}, agent.model + ) + logger.info( + "Fallback %s: reasoning_config resolved: %s", + agent.model, agent.reasoning_config, + ) + except Exception as _reasoning_err: + logger.debug( + "Failed to resolve reasoning_config for fallback %s; keeping current: %s", + agent.model, _reasoning_err, + ) + # Keep whatever reasoning_config was active — don't break the fallback swap. + # Keep the prompt's self-identity in sync with the model actually # answering, so "what model are you?" doesn't report the primary. rewrite_prompt_model_identity(agent, fb_model, fb_provider) @@ -1544,6 +1707,16 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool f"🔄 Primary model failed — switching to fallback: " f"{fb_model} via {fb_provider}" ) + # The buffered line above is dropped on successful recovery, but a + # provider/model switch is a durable state change operators must see + # even when the fallback succeeds. Record a one-shot notice that the + # success path surfaces exactly once via _emit_pending_fallback_notice + # (see run_agent.py); it is discarded on terminal failure since the + # buffered line is flushed instead. See fallback-observability fix. + agent._pending_fallback_notice = ( + f"🔄 Switched to fallback model: {old_model} via {old_provider} " + f"→ {fb_model} via {fb_provider}" + ) logger.info( "Fallback activated: %s → %s (%s)", old_model, fb_model, fb_provider, @@ -1679,18 +1852,28 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: if _lm_reasoning_effort is not None: summary_kwargs["reasoning_effort"] = _lm_reasoning_effort - # Include provider routing preferences - provider_preferences = {} - if agent.providers_allowed: - provider_preferences["only"] = agent.providers_allowed - if agent.providers_ignored: - provider_preferences["ignore"] = agent.providers_ignored - if agent.providers_order: - provider_preferences["order"] = agent.providers_order - _provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) - if _provider_sort: - provider_preferences["sort"] = _provider_sort - if provider_preferences and ( + # Merge the profile's canonical body even when routing is unset: + # profiles may always emit required metadata such as Portal tags. + provider_preferences = _provider_preferences_for_agent(agent) + profile_extra_body = {} + try: + from providers import get_provider_profile + + provider_profile = get_provider_profile(agent.provider) + if provider_profile is not None: + profile_extra_body = provider_profile.build_extra_body( + session_id=getattr(agent, "session_id", None), + provider_preferences=provider_preferences or None, + model=agent.model, + base_url=agent.base_url, + reasoning_config=agent.reasoning_config, + ) + except Exception: + pass + + if profile_extra_body: + summary_extra_body.update(profile_extra_body) + if provider_preferences and "provider" not in profile_extra_body and ( (agent.provider or "").strip().lower() == "openrouter" or agent._is_openrouter_url() ): @@ -1846,6 +2029,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= if agent._interrupt_requested: raise InterruptedError("Agent interrupted before streaming API call") + # Cron and other non-interactive, nested-pool contexts deadlock on the + # spawned worker thread (#62151). They also have no stream consumer, so the + # deltas this path produces go nowhere. Delegate to the non-streaming entry + # (which runs inline via should_use_direct_api_call) exactly like the codex + # branch below — routing through the _interruptible_api_call method keeps the + # outer loop's per-request retry/refresh seam intact. + if should_use_direct_api_call(agent): + return agent._interruptible_api_call(api_kwargs) + if agent.api_mode == "codex_responses": # Codex streams internally via _run_codex_stream. The main dispatch # in _interruptible_api_call already calls it; we just need to @@ -2383,7 +2575,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= and not reasoning_parts and not tool_calls_acc ): - raise RuntimeError( + raise EmptyStreamError( "Provider returned an empty stream with no finish_reason " "(possible upstream error or malformed SSE response)." ) @@ -2472,6 +2664,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= works unchanged. """ has_tool_use = False + # Zero-event guard parity with the chat_completions path: track + # whether the provider delivered ANY stream event. On an eventless + # stream the real Anthropic SDK's get_final_message() raises + # AssertionError (no message_start ⇒ no final-message snapshot); + # OpenAI-compat shims may instead fabricate a contentless Message + # with no stop_reason, or return None under ``python -O`` (assert + # stripped). Every one of those shapes is normalized below to + # EmptyStreamError so the shared _call() retry loop treats it as + # transient instead of surfacing a raw AssertionError or a + # fabricated "successful" empty turn. + saw_stream_event = False # Reset stale-stream timer for this attempt last_chunk_time["t"] = time.time() @@ -2499,6 +2702,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= except Exception: pass for event in stream: + saw_stream_event = True # Update stale-stream timer on every event so the # outer poll loop knows data is flowing. Without # this, the detector kills healthy long-running @@ -2559,7 +2763,38 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # this return value is discarded anyway. if agent._interrupt_requested: return None - return stream.get_final_message() + # Zero-event guard (parity with the chat_completions zero-chunk + # guard above). Real SDK: an eventless stream has no + # message_start, so get_final_message() raises AssertionError + # (final-message snapshot is None) — normalize that to + # EmptyStreamError so it gets the transient retry budget + # instead of surfacing raw. + try: + _final_message = stream.get_final_message() + except AssertionError: + if not saw_stream_event: + raise EmptyStreamError( + "Provider returned an empty stream with no events " + "(possible upstream error or malformed event stream)." + ) from None + raise + # Shim variants of the same failure: an OpenAI-compat adapter + # may fabricate a contentless Message with no stop_reason, or + # return None where the SDK assert would have fired (e.g. + # ``python -O``). A real completed response always carries a + # stop_reason, so this cannot fire on legitimate turns. + if not saw_stream_event and ( + _final_message is None + or ( + not getattr(_final_message, "content", None) + and getattr(_final_message, "stop_reason", None) is None + ) + ): + raise EmptyStreamError( + "Provider returned an empty stream with no stop_reason " + "(possible upstream error or malformed event stream)." + ) + return _final_message def _call(): import httpx as _httpx @@ -2606,6 +2841,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= e, (_httpx.ConnectError, _httpx.RemoteProtocolError, ConnectionError) ) _is_stream_parse_err = agent._is_provider_stream_parse_error(e) + _is_empty_stream = isinstance(e, EmptyStreamError) # If the stream died AFTER some tokens were delivered: # normally we don't retry (the user already saw text, @@ -2748,7 +2984,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= for phrase in _SSE_CONN_PHRASES ) - if _is_timeout or _is_conn_err or _is_sse_conn_err or _is_stream_parse_err: + if ( + _is_timeout + or _is_conn_err + or _is_sse_conn_err + or _is_stream_parse_err + or _is_empty_stream + ): # Transient network / timeout error. Retry the # streaming request with a fresh connection first. if _stream_attempt < _max_stream_retries: @@ -2791,17 +3033,32 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= mid_tool_call=False, diag=request_client_holder.get("diag"), ) - agent._buffer_status( - "❌ Provider returned malformed streaming data after " - f"{_max_stream_retries + 1} attempts. " - "The provider may be experiencing issues — " - "try again in a moment." - if _is_stream_parse_err else - "❌ Connection to provider failed after " - f"{_max_stream_retries + 1} attempts. " - "The provider may be experiencing issues — " - "try again in a moment." - ) + if _is_stream_parse_err: + _exhausted_msg = ( + "❌ Provider returned malformed streaming data after " + f"{_max_stream_retries + 1} attempts. " + "The provider may be experiencing issues — " + "try again in a moment." + ) + elif _is_empty_stream: + # The connection SUCCEEDED (stream opened) but the + # provider sent no chunks — saying "connection + # failed" here sends users chasing network issues + # when the problem is the provider/endpoint. + _exhausted_msg = ( + "❌ Provider returned an empty response stream " + f"after {_max_stream_retries + 1} attempts. " + "The provider may be experiencing issues — " + "try again in a moment." + ) + else: + _exhausted_msg = ( + "❌ Connection to provider failed after " + f"{_max_stream_retries + 1} attempts. " + "The provider may be experiencing issues — " + "try again in a moment." + ) + agent._buffer_status(_exhausted_msg) else: _err_lower = str(e).lower() _is_stream_unsupported = ( @@ -2918,9 +3175,29 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= if _hb_now - _last_heartbeat >= _HEARTBEAT_INTERVAL: _last_heartbeat = _hb_now _waiting_secs = int(_hb_now - last_chunk_time["t"]) - agent._touch_activity( - f"waiting for stream response ({_waiting_secs}s, no chunks yet)" - ) + if _waiting_secs >= _HEARTBEAT_INTERVAL: + # No chunks for 30s+ — rewrite the live spinner/status line + # so CLI/TUI/Desktop users see WHAT the wait is (slow or + # overloaded provider / long thinking pause) instead of an + # unexplained generic spinner, and WHEN recovery kicks in. + if ( + _stream_stale_timeout is not None + and _stream_stale_timeout != float("inf") + ): + _recovery = f"; auto-reconnect at {int(_stream_stale_timeout)}s" + else: + _recovery = "" + agent._emit_wait_notice( + f"⏳ waiting on {api_kwargs.get('model', 'the provider')} — " + f"{_waiting_secs}s with no output yet (provider may be " + f"slow or overloaded, or the model is thinking{_recovery})" + ) + else: + # Chunks are flowing — keep the activity tracker fresh but + # leave the live display alone. + agent._touch_activity( + f"waiting for stream response ({_waiting_secs}s, no chunks yet)" + ) # Detect stale streams: connections kept alive by SSE pings # but delivering no real chunks. Kill the client so the @@ -2963,6 +3240,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # Reset the timer so we don't kill repeatedly while # the inner thread processes the closure. last_chunk_time["t"] = time.time() + agent._emit_wait_notice( + f"⚠ no output from provider for {int(_stale_elapsed)}s — " + f"reconnecting..." + ) agent._touch_activity( f"stale stream detected after {int(_stale_elapsed)}s, reconnecting" ) diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 4d138ce6e631..bce372ebb5da 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -288,6 +288,13 @@ _RESPONSES_BUILTIN_TOOL_TYPES = { _RESPONSE_MESSAGE_STATUSES = {"completed", "incomplete", "in_progress"} +# The Responses API rejects input[].id longer than this with a non-retryable +# HTTP 400 ("string too long"). Codex-issued assistant message ids are +# server-assigned base64 blobs that can run 400+ chars, while Hermes-minted +# ids (msg_...) stay well under this cap and are worth keeping for +# prefix-cache hits. Drop only the oversized ones on replay. +_MAX_RESPONSES_ITEM_ID_LENGTH = 64 + def _normalize_responses_message_status(value: Any, *, default: str = "completed") -> str: """Normalize a Responses assistant message status for replay. @@ -307,6 +314,7 @@ def _chat_messages_to_responses_input( messages: List[Dict[str, Any]], *, is_xai_responses: bool = False, + is_github_responses: bool = False, replay_encrypted_reasoning: bool = True, current_issuer_kind: Optional[str] = None, ) -> List[Dict[str, Any]]: @@ -331,6 +339,16 @@ def _chat_messages_to_responses_input( items from the conversation history and threads ``replay_enabled=False`` through this converter so subsequent turns send no reasoning items. + ``is_github_responses`` drops the ``id`` field from replayed + ``codex_message_items`` regardless of length. The Copilot backend + (api.githubcopilot.com/responses) binds these ids to a specific + backend "connection" — credential-pool rotation, a gateway restart, + or routine load-balancer churn between turns all invalidate it — and + rejects a stale id with HTTP 401 "input item ID does not belong to + this connection" even for short ids (see #32716). ``phase``/ + ``status``/``content`` are still replayed; only ``id`` is unsafe to + reuse across a Copilot connection. + ``current_issuer_kind`` enables a per-item cross-issuer guard. The Responses API's ``encrypted_content`` blob is decryptable only by the endpoint that minted it — replaying a Codex-issued blob against xAI @@ -463,8 +481,14 @@ def _chat_messages_to_responses_input( "content": normalized_content_parts, } item_id = raw_item.get("id") - if isinstance(item_id, str) and item_id.strip(): - replay_item["id"] = item_id.strip() + if ( + not is_github_responses + and isinstance(item_id, str) + and item_id.strip() + ): + stripped_id = item_id.strip() + if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH: + replay_item["id"] = stripped_id phase = raw_item.get("phase") if isinstance(phase, str) and phase.strip(): replay_item["phase"] = phase.strip() @@ -576,7 +600,11 @@ def _chat_messages_to_responses_input( # Input preflight / validation # --------------------------------------------------------------------------- -def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]: +def _preflight_codex_input_items( + raw_items: Any, + *, + is_github_responses: bool = False, +) -> List[Dict[str, Any]]: if not isinstance(raw_items, list): raise ValueError("Codex Responses input must be a list of input items.") @@ -717,8 +745,14 @@ def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]: "content": normalized_content, } item_id = item.get("id") - if isinstance(item_id, str) and item_id.strip(): - normalized_item["id"] = item_id.strip() + if ( + not is_github_responses + and isinstance(item_id, str) + and item_id.strip() + ): + stripped_id = item_id.strip() + if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH: + normalized_item["id"] = stripped_id phase = item.get("phase") if isinstance(phase, str) and phase.strip(): normalized_item["phase"] = phase.strip() @@ -790,6 +824,7 @@ def _preflight_codex_api_kwargs( api_kwargs: Any, *, allow_stream: bool = False, + is_github_responses: bool = False, ) -> Dict[str, Any]: if not isinstance(api_kwargs, dict): raise ValueError("Codex Responses request must be a dict.") @@ -811,7 +846,10 @@ def _preflight_codex_api_kwargs( instructions = str(instructions) instructions = instructions.strip() or DEFAULT_AGENT_IDENTITY - normalized_input = _preflight_codex_input_items(api_kwargs.get("input")) + normalized_input = _preflight_codex_input_items( + api_kwargs.get("input"), + is_github_responses=is_github_responses, + ) tools = api_kwargs.get("tools") normalized_tools = None @@ -1080,6 +1118,22 @@ def _normalize_codex_response( differs from the one that minted the encrypted_content blob and drop the item instead of triggering HTTP 400 invalid_encrypted_content. """ + response_status = getattr(response, "status", None) + if isinstance(response_status, str): + response_status = response_status.strip().lower() + else: + response_status = None + + incomplete_details = getattr(response, "incomplete_details", None) + incomplete_reason = "" + if isinstance(incomplete_details, dict): + incomplete_reason = str(incomplete_details.get("reason") or "").strip().lower() + elif incomplete_details is not None: + incomplete_reason = str(getattr(incomplete_details, "reason", "") or "").strip().lower() + response_incomplete_content_filter = ( + response_status == "incomplete" and incomplete_reason == "content_filter" + ) + output = getattr(response, "output", None) if not isinstance(output, list) or not output: # The Codex backend can return empty output when the answer was @@ -1096,15 +1150,18 @@ def _normalize_codex_response( content=[SimpleNamespace(type="output_text", text=out_text.strip())], )] response.output = output + elif response_incomplete_content_filter: + # This is a deterministic provider safety block, not a partial + # answer. Synthesize an empty message so finish_reason below becomes + # content_filter and the conversation loop can fallback/surface it + # instead of burning three continuation attempts. + output = [SimpleNamespace( + type="message", role="assistant", status="completed", content=[] + )] + response.output = output else: raise RuntimeError("Responses API returned no output items") - response_status = getattr(response, "status", None) - if isinstance(response_status, str): - response_status = response_status.strip().lower() - else: - response_status = None - if response_status in {"failed", "cancelled"}: error_obj = getattr(response, "error", None) error_msg = _format_responses_error(error_obj, response_status) @@ -1322,6 +1379,45 @@ def _normalize_codex_response( # so the model keeps its chain-of-thought on the retry. final_text = "" + # ── Reasoning-channel answer salvage (xAI grok) ────────────── + # grok-4.x on the xAI /v1/responses surface sometimes emits its final + # answer inside the reasoning item instead of as a ``message`` output + # item, marking where the answer starts with grok's internal + # ```` delimiter. Without salvage, the reasoning-only rule + # below classifies the turn ``incomplete`` — and because reasoning + # items on this surface carry no ``encrypted_content``, the interim + # message replays as nothing, so every continuation request is + # byte-identical to the one that just failed. The turn burns its 3 + # retries and dies with "Codex response remained incomplete after 3 + # continuation attempts" even though the answer was produced on the + # first attempt. Observed live with grok-4.20 on xai-oauth + # (2026-07-13). Promote the delimited tail to assistant content and + # keep the untagged prefix as thinking text. + if ( + issuer_kind == "xai_responses" + and not final_text + and not tool_calls + and reasoning_parts + ): + joined_reasoning = "\n\n".join(reasoning_parts) + marker = joined_reasoning.rfind("") + if marker != -1: + salvaged = joined_reasoning[marker + len(""):] + closing = salvaged.find("") + if closing != -1: + salvaged = salvaged[:closing] + salvaged = salvaged.strip() + if salvaged: + logger.warning( + "xAI response delivered its final answer inside the " + "reasoning channel ( delimiter); promoting " + "%d chars to assistant content.", + len(salvaged), + ) + final_text = salvaged + reasoning_prefix = joined_reasoning[:marker].strip() + reasoning_parts = [reasoning_prefix] if reasoning_prefix else [] + assistant_message = SimpleNamespace( content=final_text, tool_calls=tool_calls, @@ -1334,6 +1430,8 @@ def _normalize_codex_response( if tool_calls: finish_reason = "tool_calls" + elif response_incomplete_content_filter: + finish_reason = "content_filter" elif leaked_tool_call_text: finish_reason = "incomplete" elif saw_streaming_or_item_incomplete: @@ -1342,12 +1440,28 @@ def _normalize_codex_response( finish_reason = "incomplete" elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text: # Response contains only reasoning (encrypted thinking state and/or - # human-readable summary) with no visible content or tool calls. The - # model is still thinking and needs another turn to produce the actual - # answer. Marking this as "stop" would send it into the empty-content - # retry loop which burns retries then fails — treat it as incomplete so - # the Codex continuation path handles it correctly. - finish_reason = "incomplete" + # human-readable summary) with no visible content or tool calls. + # + # For the specially-handled backends (Codex, xAI, GitHub/Copilot), + # reasoning-only with status="completed" means "the model is still + # thinking and needs another turn" — treat it as incomplete so the + # Codex continuation path retries instead of falling into the + # empty-content retry loop. + # + # For all other backends (other:, etc.), trust the provider's + # own response.status signal. When status == "completed" and no items + # are queued/in_progress/incomplete, reasoning alone is a valid final + # state — forcing "incomplete" causes multi-minute stalls as the + # continuation path re-issues calls (3 retries × up to 240s each). + # See https://github.com/NousResearch/hermes-agent/issues/64434 + if response_status == "completed" and issuer_kind not in ( + "codex_backend", + "xai_responses", + "github_responses", + ): + finish_reason = "stop" + else: + finish_reason = "incomplete" else: finish_reason = "stop" return assistant_message, finish_reason diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 2077d2fddcab..c1e46e8a4604 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -113,6 +113,15 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]: usage = getattr(turn, "token_usage_last", None) if not isinstance(usage, dict) or not usage: + compressor = getattr(agent, "context_compressor", None) + if ( + compressor is not None + and getattr(compressor, "awaiting_real_usage_after_compression", False) + ): + # No usage means this turn cannot adjudicate the pending compaction. + # Consume the marker so a later unrelated reading is not charged to + # it and preflight deferral cannot stay latched indefinitely. + compressor.update_from_response({}) if agent._session_db and agent.session_id: try: if not agent._session_db_created: @@ -120,6 +129,9 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]: agent._session_db.update_token_counts( agent.session_id, model=agent.model, + billing_provider=agent.provider, + billing_base_url=agent.base_url, + billing_mode="subscription_included", api_call_count=1, ) except Exception as exc: @@ -267,6 +279,18 @@ def _record_codex_app_server_compaction( compressor, "compression_count", 0 ) + 1 compressor.last_compression_rough_tokens = approx_tokens or 0 + # The app server has already completed a real compaction boundary. Its + # usage update (when supplied) is therefore the same real-vs-real + # effectiveness verdict used by the normal compression path. + record_boundary = getattr( + type(compressor), "record_completed_compaction", None + ) + if callable(record_boundary): + # Codex owns this summary. A prior Hermes deterministic-fallback + # flag must not leak into the native boundary's quality verdict. + record_boundary(compressor, used_fallback=False) + elif hasattr(compressor, "_verify_compaction_cleared_threshold"): + compressor._verify_compaction_cleared_threshold = True if not getattr(turn, "token_usage_last", None): compressor.last_prompt_tokens = -1 compressor.last_completion_tokens = 0 diff --git a/agent/coding_context.py b/agent/coding_context.py index 00f6d996d478..db38ab3daa8a 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -56,6 +56,7 @@ import logging import os import re import subprocess +import tempfile from dataclasses import dataclass from pathlib import Path from typing import Any, Optional @@ -412,10 +413,18 @@ def _marker_root(cwd: Path) -> Optional[Path]: """ current = cwd.resolve() home = _home() + # Shared world-writable temp roots are never project roots: a stray + # manifest in /tmp (left by any process) must not flip every session + # whose cwd lives under the temp dir into the coding posture. Same + # reasoning as the $HOME skip below. + try: + temp_root = Path(tempfile.gettempdir()).resolve() + except Exception: + temp_root = None for depth, parent in enumerate([current, *current.parents]): if depth > 6: break - if parent == home: + if parent == home or (temp_root is not None and parent == temp_root): continue for marker in _PROJECT_MARKERS: if (parent / marker).exists(): diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 45eb25e1ca0b..d53c5d5a5488 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -193,8 +193,10 @@ _HISTORICAL_SUMMARY_PREFIXES = ( _MIN_SUMMARY_TOKENS = 2000 # Proportion of compressed content to allocate for summary _SUMMARY_RATIO = 0.20 -# Absolute ceiling for summary tokens (even on very large context windows) -_SUMMARY_TOKENS_CEILING = 12_000 +# Absolute ceiling for summary tokens (even on very large context windows). +# Summaries must stay within a 1K-10K token envelope — anything larger is +# itself a context-pressure source and slows every compaction. +_SUMMARY_TOKENS_CEILING = 10_000 # Placeholder used when pruning old tool results _PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]" @@ -227,6 +229,16 @@ _AUTO_FOCUS_MAX_CHARS = 700 # back the old large-tool-output case where nothing can be compacted. _MAX_TAIL_MESSAGE_FLOOR = 8 +# Models with context windows below this get their compression threshold +# floored at ``_SMALL_CTX_THRESHOLD_PERCENT`` (raise-only — an explicitly +# higher user/model threshold always wins). At the default 50% trigger a +# 128K-262K model compacts with only ~64-131K consumed; the incompressible +# floor (system prompt + tool schemas + protected tail + rolling summary) +# eats most of the reclaimed headroom, so compaction re-fires every 1-2 +# turns and the session spends most of its wall-clock summarizing. +_SMALL_CTX_WINDOW_LIMIT = 512_000 +_SMALL_CTX_THRESHOLD_PERCENT = 0.75 + _PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+") @@ -574,6 +586,42 @@ def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, An return result if changed else messages +def _image_part_label(part: Dict[str, Any]) -> str: + """Render a multimodal image part as a short text label for the summarizer. + + Keeps a real, referenceable URL when the image lives at an http(s) + address — the summary can then preserve the handle so the agent (or a + later vision_analyze call) can still reach the image after compaction. + Base64 ``data:`` URLs carry no reusable reference and would flood the + summarizer input, so they collapse to ``[image]``. + """ + url = "" + if isinstance(part.get("image_url"), dict): + url = str(part["image_url"].get("url") or "") + elif isinstance(part.get("image_url"), str): + url = part["image_url"] + elif isinstance(part.get("url"), str): + url = part["url"] + if url.startswith(("http://", "https://")): + return f"[image: {url}]" + return "[image]" + + +def _str_arg(args: dict, key: str, default: str = "") -> str: + """Safely get a string argument from parsed tool args. + + LLMs sometimes return non-string parameter values (e.g. bool, int) for + tool calls. Calling ``len()`` / ``.count()`` / slicing on those causes + ``TypeError`` / ``AttributeError`` which crashes context compression. + This helper coerces any value to ``str`` so downstream code can assume + a string is always returned. + """ + val = args.get(key, default) + if isinstance(val, str): + return val + return str(val) if val is not None else default + + def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str: """Create an informative 1-line summary of a tool call + result. @@ -586,18 +634,37 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> [terminal] ran `npm test` -> exit 0, 47 lines output [read_file] read config.py from line 1 (1,200 chars) [search_files] content search for 'compress' in agent/ -> 12 matches + + Never raises: models sometimes emit non-string argument values (bool, + int, None) and the args here come from persisted session history, so a + single malformed historical call must not crash compression — which + retries on the same history and would crash-loop. Individual branches + coerce the values they slice/measure (keeping summaries informative); + this wrapper is the backstop for anything they miss. """ + try: + return _summarize_tool_result_unguarded(tool_name, tool_args, tool_content) + except Exception as exc: # noqa: BLE001 — a summary must never crash compression + logger.debug("Tool-result summary failed for %s: %s", tool_name, exc) + _len = len(tool_content) if isinstance(tool_content, str) else 0 + return f"[{tool_name}] ({_len:,} chars result)" + + +def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_content: str) -> str: + """Build the summary line (unguarded; see ``_summarize_tool_result``).""" try: args = json.loads(tool_args) if tool_args else {} except (json.JSONDecodeError, TypeError): args = {} + if not isinstance(args, dict): + args = {} content = tool_content or "" content_len = len(content) line_count = content.count("\n") + 1 if content.strip() else 0 if tool_name == "terminal": - cmd = args.get("command", "") + cmd = _str_arg(args, "command") if len(cmd) > 80: cmd = cmd[:77] + "..." exit_match = re.search(r'"exit_code"\s*:\s*(-?\d+)', content) @@ -611,7 +678,7 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> if tool_name == "write_file": path = args.get("path", "?") - written_lines = args.get("content", "").count("\n") + 1 if args.get("content") else "?" + written_lines = _str_arg(args, "content").count("\n") + 1 if args.get("content") else "?" return f"[write_file] wrote to {path} ({written_lines} lines)" if tool_name == "search_files": @@ -640,20 +707,30 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> if tool_name == "web_extract": urls = args.get("urls", []) - url_desc = urls[0] if isinstance(urls, list) and urls else "?" + first = urls[0] if isinstance(urls, list) and urls else "?" + # web_search results are dicts ({"url"/"href": ...}) and models often + # forward them straight into web_extract. Unwrap to the URL string so + # the summary stays readable and the ``+=`` below never hits the + # ``dict + str`` TypeError that would abort pre-compression pruning. + if isinstance(first, dict): + first = first.get("url") or first.get("href") or "?" + elif not isinstance(first, str): + first = "?" + url_desc = first if isinstance(urls, list) and len(urls) > 1: url_desc += f" (+{len(urls) - 1} more)" return f"[web_extract] {url_desc} ({content_len:,} chars)" if tool_name == "delegate_task": - goal = args.get("goal", "") + goal = _str_arg(args, "goal") if len(goal) > 60: goal = goal[:57] + "..." return f"[delegate_task] '{goal}' ({content_len:,} chars result)" if tool_name == "execute_code": - code_preview = (args.get("code") or "")[:60].replace("\n", " ") - if len(args.get("code", "")) > 60: + code_str = _str_arg(args, "code") + code_preview = code_str[:60].replace("\n", " ") + if len(code_str) > 60: code_preview += "..." return f"[execute_code] `{code_preview}` ({line_count} lines output)" @@ -662,7 +739,7 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> return f"[{tool_name}] name={name} ({content_len:,} chars)" if tool_name == "vision_analyze": - question = args.get("question", "")[:50] + question = _str_arg(args, "question")[:50] return f"[vision_analyze] '{question}' ({content_len:,} chars)" if tool_name == "memory": @@ -718,12 +795,16 @@ class ContextCompressor(ContextEngine): self._context_probe_persistable = False self._previous_summary = None self._last_summary_error = None + self._consecutive_timeout_failures = 0 self._last_summary_dropped_count = 0 self._last_summary_fallback_used = False self._last_aux_model_failure_error = None self._last_aux_model_failure_model = None self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 + self._fallback_compression_streak = 0 + self._verify_compaction_cleared_threshold = False + self._last_compression_made_progress = False self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session self._last_summary_error = None self._last_compress_aborted = False @@ -753,12 +834,16 @@ class ContextCompressor(ContextEngine): """ self._previous_summary = None self._last_summary_error = None + self._consecutive_timeout_failures = 0 self._last_summary_dropped_count = 0 self._last_summary_fallback_used = False self._last_aux_model_failure_error = None self._last_aux_model_failure_model = None self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 + self._fallback_compression_streak = 0 + self._verify_compaction_cleared_threshold = False + self._last_compression_made_progress = False self._summary_failure_cooldown_until = 0.0 self._last_compress_aborted = False self._context_probed = False @@ -774,12 +859,85 @@ class ContextCompressor(ContextEngine): self._session_id = session_id or "" self._summary_failure_cooldown_until = 0.0 self._last_summary_error = None + self._consecutive_timeout_failures = 0 + self._fallback_compression_streak = 0 self.get_active_compression_failure_cooldown() + self._load_fallback_compression_streak() def on_session_start(self, session_id: str, **kwargs) -> None: """Bind session-scoped compression state for a new or resumed session.""" super().on_session_start(session_id, **kwargs) - self.bind_session_state(kwargs.get("session_db", getattr(self, "_session_db", None)), session_id) + boundary_reason = kwargs.get("boundary_reason") + old_session_id = kwargs.get("old_session_id") + session_db = kwargs.get("session_db", getattr(self, "_session_db", None)) + previous_fallback_streak = self._fallback_compression_streak + if boundary_reason == "compression" and old_session_id: + getter = getattr(session_db, "get_compression_fallback_streak", None) + if callable(getter): + try: + stored_streak = getter(old_session_id) + if isinstance(stored_streak, (int, float, str)): + previous_fallback_streak = max(0, int(stored_streak)) + except (TypeError, ValueError, sqlite3.Error) as exc: + logger.debug("compression parent fallback streak lookup failed: %s", exc) + except Exception as exc: + logger.debug( + "compression parent fallback streak lookup failed (non-sqlite): %s", + exc, + ) + self.bind_session_state(session_db, session_id) + if boundary_reason == "compression": + # Rotation creates a fresh child row before this callback. Preserve + # the logical conversation's streak until boundary bookkeeping + # persists the updated value onto the child row. + self._fallback_compression_streak = previous_fallback_streak + + def _load_fallback_compression_streak(self) -> None: + session_db = getattr(self, "_session_db", None) + session_id = getattr(self, "_session_id", "") + getter = getattr(session_db, "get_compression_fallback_streak", None) + if not session_id or not callable(getter): + return + try: + stored_streak = getter(session_id) + self._fallback_compression_streak = max( + 0, + int(stored_streak) + if isinstance(stored_streak, (int, float, str)) + else 0, + ) + except (TypeError, ValueError, sqlite3.Error) as exc: + logger.debug("compression fallback streak lookup failed: %s", exc) + except Exception as exc: + logger.debug("compression fallback streak lookup failed (non-sqlite): %s", exc) + + def _persist_fallback_compression_streak(self) -> None: + session_db = getattr(self, "_session_db", None) + session_id = getattr(self, "_session_id", "") + setter = getattr(session_db, "set_compression_fallback_streak", None) + if not session_id or not callable(setter): + return + try: + setter(session_id, self._fallback_compression_streak) + except sqlite3.Error as exc: + logger.debug("compression fallback streak persist failed: %s", exc) + except Exception as exc: + logger.debug("compression fallback streak persist failed (non-sqlite): %s", exc) + + def record_completed_compaction(self, *, used_fallback: bool = False) -> None: + """Record one completed boundary and its summary quality.""" + self._verify_compaction_cleared_threshold = True + if used_fallback: + self._fallback_compression_streak += 1 + if not self.quiet_mode: + logger.warning( + "Compaction completed with a deterministic fallback summary. " + "fallback_compression_streak=%d", + self._fallback_compression_streak, + ) + elif self._fallback_compression_streak: + self._fallback_compression_streak = 0 + self._persist_fallback_compression_streak() def get_active_compression_failure_cooldown(self) -> Optional[Dict[str, Any]]: """Return the live compression-failure cooldown for the bound session.""" @@ -850,6 +1008,7 @@ class ContextCompressor(ContextEngine): def _clear_compression_failure_cooldown(self) -> None: self._summary_failure_cooldown_until = 0.0 self._last_summary_error = None + self._consecutive_timeout_failures = 0 session_db = getattr(self, "_session_db", None) session_id = getattr(self, "_session_id", "") @@ -877,12 +1036,30 @@ class ContextCompressor(ContextEngine): max_tokens: int | None = None, ) -> None: """Update model info after a model switch or fallback activation.""" + runtime_changed = any(( + model != self.model, + provider != self.provider, + base_url != self.base_url, + api_mode != self.api_mode, + )) self.model = model self.base_url = base_url self.api_key = api_key self.provider = provider self.api_mode = api_mode self.context_length = context_length + # Re-apply the small-context threshold floor for the NEW window, + # starting from the originally-configured percent (not the possibly + # floored live value) so a small -> large switch drops back to the + # configured threshold and a large -> small switch gains the floor. + # Guard with getattr: compressors unpickled/constructed before this + # attribute existed fall back to the live value. + _configured_pct = getattr( + self, "_configured_threshold_percent", self.threshold_percent, + ) + self.threshold_percent = self._effective_threshold_percent( + context_length, _configured_pct, + ) # max_tokens=None here means "caller didn't specify" → keep the existing # output reservation. A switch that genuinely changes the output budget # passes the new value explicitly. (#43547) @@ -920,6 +1097,11 @@ class ContextCompressor(ContextEngine): self.last_compression_rough_tokens = 0 self.awaiting_real_usage_after_compression = False self._ineffective_compression_count = 0 + if runtime_changed: + self._fallback_compression_streak = 0 + self._persist_fallback_compression_streak() + self._verify_compaction_cleared_threshold = False + self._last_compression_made_progress = False # When the MINIMUM_CONTEXT_LENGTH floor meets/exceeds a small context # window, compacting at the percentage (50% → 32K of a 64K window) wastes @@ -945,6 +1127,23 @@ class ContextCompressor(ContextEngine): return None return ivalue if ivalue > 0 else None + @staticmethod + def _effective_threshold_percent( + context_length: int, threshold_percent: float, + ) -> float: + """Apply the small-context threshold floor (raise-only). + + Models under ``_SMALL_CTX_WINDOW_LIMIT`` (512K) trigger at no less + than ``_SMALL_CTX_THRESHOLD_PERCENT`` (75%) of the window. An + explicitly higher threshold (user config or per-model autoraise, + e.g. Codex gpt-5.5's 85%) always wins; only lower values are raised. + Large-context models keep the configured value — at 512K+ the default + 50% trigger already leaves ample post-compaction headroom. + """ + if context_length and context_length < _SMALL_CTX_WINDOW_LIMIT: + return max(threshold_percent, _SMALL_CTX_THRESHOLD_PERCENT) + return threshold_percent + @staticmethod def _compute_threshold_tokens( context_length: int, threshold_percent: float, max_tokens: int | None = None, @@ -1032,6 +1231,18 @@ class ContextCompressor(ContextEngine): config_context_length=config_context_length, provider=provider, ) + # Small-context threshold floor: models under 512K trigger at >=75% + # so compaction doesn't fire with half the window still free (the + # incompressible floor makes 50%-triggered compaction thrash on + # 128K-262K models). Raise-only; must run AFTER context_length is + # resolved and BEFORE threshold_tokens is derived. The pre-floor + # value is kept so update_model() can re-derive for a new window + # (switching small -> large must drop back to the configured value). + self._configured_threshold_percent = self.threshold_percent + self.threshold_percent = self._effective_threshold_percent( + self.context_length, self.threshold_percent, + ) + threshold_percent = self.threshold_percent # Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if # the percentage would suggest a lower value. This prevents premature # compression on large-context models at 50% while keeping the % sane @@ -1078,6 +1289,16 @@ class ContextCompressor(ContextEngine): # Anti-thrashing: track whether last compression was effective self._last_compression_savings_pct: float = 100.0 self._ineffective_compression_count: int = 0 + # Consecutive completed deterministic-fallback boundaries. Unlike the + # real-usage effectiveness counter, ordinary fitting responses must not + # reset this breaker; only a healthy completed summary does. + self._fallback_compression_streak: int = 0 + # Set after a completed compression boundary; consumed by the next + # provider-reported prompt count in update_from_response(). + self._verify_compaction_cleared_threshold: bool = False + # Lets the boundary wrapper distinguish a completed rewrite from a + # no-op/abort without inferring progress from message-list length. + self._last_compression_made_progress: bool = False self._summary_failure_cooldown_until: float = 0.0 self._last_summary_error: Optional[str] = None # When summary generation fails and a static fallback is inserted, @@ -1124,8 +1345,50 @@ class ContextCompressor(ContextEngine): if self.last_prompt_tokens < self.threshold_tokens: if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0: self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens + # Any real provider reading below the trigger proves the prompt + # fits again. Clear the real-usage effectiveness latch even + # when this response was not immediately after compaction. The + # independent fallback streak is boundary-scoped and survives + # ordinary fitting responses during context regrowth. + self._ineffective_compression_count = 0 else: self.last_rough_tokens_when_real_prompt_fit = 0 + + # Anti-thrashing verdict, judged HERE because this is the only place + # that sees the provider's real prompt count for the just-compacted + # conversation. Effectiveness is "did the prompt get under the + # threshold?", not "did the message list shrink?": compaction can + # only shrink messages, while the system prompt and tool schemas are + # an incompressible floor (with 50+ tools, 20-30K tokens — see + # #14695). When that floor alone meets the threshold, every pass + # shrinks messages by a healthy margin yet leaves the prompt over the + # line, so the next turn compacts again, forever. + # + # It must NOT live in should_compress(): that runs twice per turn + # with two different measures (a rough preflight estimate and the + # real post-response count, #36718), and the rough one can dip below + # the threshold and reset the strike every turn, re-opening the loop. + # Keying on real usage compares like with like and fires exactly once + # per compaction. + if self._verify_compaction_cleared_threshold: + if self.last_prompt_tokens >= self.threshold_tokens: + self._ineffective_compression_count += 1 + if not self.quiet_mode: + logger.warning( + "Compaction did not clear the threshold: %d real " + "tokens still >= %d. The incompressible prompt " + "(system prompt + tool schemas) may already exceed " + "it, in which case shrinking messages cannot help. " + "ineffective_compression_count=%d", + self.last_prompt_tokens, self.threshold_tokens, + self._ineffective_compression_count, + ) + else: + self._ineffective_compression_count = 0 + # Consume the pending-verification flag once real usage arrives, whether + # or not prompt_tokens was reported, so a usage-less response can't leave + # it armed for a later, unrelated reading. + self._verify_compaction_cleared_threshold = False self.awaiting_real_usage_after_compression = False def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool: @@ -1180,6 +1443,10 @@ class ContextCompressor(ContextEngine): tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens if tokens < self.threshold_tokens: return False + return not self._automatic_compression_blocked() + + def _automatic_compression_blocked(self) -> bool: + """Return whether automatic compaction is in cooldown or tripped.""" # Do not trigger compression while the summary LLM is in cooldown. # On a 429/transient failure _generate_summary() sets a cooldown and # returns None; compress() then inserts a static fallback marker and @@ -1196,18 +1463,23 @@ class ContextCompressor(ContextEngine): "Compression deferred — summary LLM in cooldown for %.0fs more", _cooldown_remaining, ) - return False + return True # Anti-thrashing: back off if recent compressions were ineffective - if self._ineffective_compression_count >= 2: + if ( + self._ineffective_compression_count >= 2 + or self._fallback_compression_streak >= 2 + ): if not self.quiet_mode: logger.warning( - "Compression skipped — last %d compressions saved <10%% each. " - "Consider /new to start a fresh session, or /compress " - "for focused compression.", + "Compression skipped — repeated compaction attempts did not " + "restore healthy context. ineffective=%d fallback=%d. " + "Consider /new to start fresh, or /compress for " + "focused compression.", self._ineffective_compression_count, + self._fallback_compression_streak, ) - return False - return True + return True + return False # ------------------------------------------------------------------ # Tool output pruning (cheap pre-pass, no LLM call) @@ -1410,11 +1682,43 @@ class ContextCompressor(ContextEngine): (API keys, tokens, passwords) from leaking into the summary that gets sent to the auxiliary model and persisted across compactions. """ + # Lazy import (matches title_generator.py) — agent_runtime_helpers + # pulls in heavy transitive imports we don't want at module load. + from agent.agent_runtime_helpers import strip_think_blocks + parts = [] for msg in turns: role = msg.get("role", "unknown") - content = redact_sensitive_text(msg.get("content") or "") + content = msg.get("content") + if isinstance(content, list): + text_parts: list[str] = [] + for part in content: + if isinstance(part, dict): + ptype = part.get("type") + if ptype == "text": + text_parts.append(part.get("text", "")) + elif ptype in {"image", "image_url", "input_image"}: + text_parts.append(_image_part_label(part)) + else: + # Unknown part type — keep a marker so the + # summarizer knows content existed here. + text_parts.append(f"[{ptype or 'attachment'}]") + elif isinstance(part, str): + text_parts.append(part) + content = "\n".join(text_parts) + content = redact_sensitive_text(content or "") content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content) + # Strip inline reasoning blocks (, , etc.) from + # assistant content before it reaches the summarizer. Reasoning + # traces are transient scratch work — feeding them to the aux + # model wastes summarizer context and risks scratch-work + # conclusions being preserved as facts in the summary. The native + # ``reasoning`` message field is already excluded (only + # ``content`` is serialized); this closes the inline-tag path + # used when native thinking is disabled or the provider inlines + # traces into content. + if role == "assistant" and content: + content = strip_think_blocks(None, content) # Tool results: keep enough content for the summarizer if role == "tool": @@ -1880,7 +2184,15 @@ This compaction should PRIORITISE preserving all information related to the focu "api_mode": self.api_mode, }, "messages": [{"role": "user", "content": prompt}], - "max_tokens": int(summary_budget * 1.3), + # NO max_tokens: the output cap must never truncate a summary. + # ``summary_budget`` is prompt-level guidance only ("Target ~N + # tokens" above). Most OpenAI-compatible wires already omit the + # param (see _build_call_kwargs), but the Anthropic Messages + # wire and NVIDIA NIM forward it — a hard cap there cut + # summaries mid-section (thinking models burn the cap on + # reasoning first), producing truncated/thinking-only + # summaries and compaction loops. Omitting lets the adapter + # fall back to the model's native output ceiling. # timeout resolved from auxiliary.compression.timeout config by call_llm } if self.summary_model: @@ -1920,6 +2232,16 @@ This compaction should PRIORITISE preserving all information related to the focu f"(provider={self.provider or 'auto'} " f"model={self.summary_model or self.model})" ) + # Strip reasoning blocks the summarizer model may have emitted + # (... etc. from thinking models like MiniMax, + # DeepSeek, QwQ). Without this the trace is stored in + # _previous_summary, injected into the conversation, AND fed back + # into every subsequent iterative-update prompt — compounding + # token bloat across compactions. Mirrors title_generator.py. + from agent.agent_runtime_helpers import strip_think_blocks + stripped = strip_think_blocks(None, content).strip() + if stripped: + content = stripped # Redact the summary output as well — the summarizer LLM may # ignore prompt instructions and echo back secrets verbatim. summary = redact_sensitive_text(content.strip()) @@ -1970,6 +2292,7 @@ This compaction should PRIORITISE preserving all information related to the focu _is_timeout = ( _status in {408, 429, 502, 504} or "timeout" in _err_str + or "timed out" in _err_str ) # Non-JSON / malformed-body responses from misconfigured providers # or proxies (e.g. an HTML 502 page returned with @@ -2059,7 +2382,30 @@ This compaction should PRIORITISE preserving all information related to the focu # Transient errors (timeout, rate limit, network, JSON decode, # streaming premature-close) — shorter cooldown for JSON decode and # streaming-closed since those conditions can self-resolve quickly. - _transient_cooldown = 30 if (_is_json_decode or _is_streaming_closed) else 60 + # Timeout-class failures escalate with consecutive occurrences: + # a session whose transcript structurally exceeds what the + # summary route can produce within its deadline will fail the + # same way every time, and re-burning the full timeout every + # 60s turns each subsequent turn into a multi-minute stall + # (#62452). 60s → 300s → 900s (capped); any successful summary + # resets the streak via _clear_compression_failure_cooldown(). + # Timeout takes precedence over the streaming-closed short rung: + # a "timed out" error also matches _is_connection_error, but a + # deadline exhaustion is the structural repeat-offender class, + # not a transient mid-stream drop. + if _is_timeout: + self._consecutive_timeout_failures = ( + getattr(self, "_consecutive_timeout_failures", 0) + 1 + ) + _TIMEOUT_COOLDOWN_LADDER = (60, 300, 900) + _transient_cooldown = _TIMEOUT_COOLDOWN_LADDER[ + min(self._consecutive_timeout_failures, + len(_TIMEOUT_COOLDOWN_LADDER)) - 1 + ] + elif _is_json_decode or _is_streaming_closed: + _transient_cooldown = 30 + else: + _transient_cooldown = 60 err_text = str(e).strip() or e.__class__.__name__ if len(err_text) > 220: err_text = err_text[:217].rstrip() + "..." @@ -2734,6 +3080,7 @@ This compaction should PRIORITISE preserving all information related to the focu self._last_aux_model_failure_error = None self._last_aux_model_failure_model = None self._last_compress_aborted = False + self._last_compression_made_progress = False # NOTE: do NOT reset _last_summary_auth_failure or # _last_summary_network_failure here. These flags are set by # _generate_summary() on a terminal failure and are already cleared on @@ -2754,10 +3101,21 @@ This compaction should PRIORITISE preserving all information related to the focu # Only need head + 3 tail messages minimum (token budget decides the real tail size) _min_for_compress = self._protect_head_size(messages) + 3 + 1 if n_messages <= _min_for_compress: + # Record the no-op, exactly as the sibling "no compressable window" + # branch below does (#40803). Returning without touching the + # anti-thrashing counter leaves should_compress() saying True on a + # transcript that can never shrink: when the prompt sits above the + # threshold because of the incompressible floor (system prompt + + # tool schemas), every subsequent turn re-fires a compaction that + # returns here unchanged, and the CLI appears frozen. + self._ineffective_compression_count += 1 + self._last_compression_savings_pct = 0.0 if not self.quiet_mode: logger.warning( - "Cannot compress: only %d messages (need > %d)", + "Cannot compress: only %d messages (need > %d). " + "ineffective_compression_count=%d", n_messages, _min_for_compress, + self._ineffective_compression_count, ) return messages @@ -3053,15 +3411,26 @@ This compaction should PRIORITISE preserving all information related to the focu compressed = _strip_historical_media(compressed) new_estimate = estimate_messages_tokens_rough(compressed) - saved_estimate = display_tokens - new_estimate - # Anti-thrashing: track compression effectiveness - savings_pct = (saved_estimate / display_tokens * 100) if display_tokens > 0 else 0 + # Anti-thrashing: measure effectiveness on a like-for-like basis. + # + # ``display_tokens`` is usually ``current_tokens`` — the provider's real + # prompt count, which includes the system prompt and tool schemas. + # ``new_estimate`` covers the messages ONLY. Comparing the two makes a + # compaction that freed almost nothing look like it saved ~96%, so the + # counter below resets every pass and the anti-thrashing guard is dead + # code. Compaction can only shrink messages, so score it against the + # messages it was given. + pre_estimate = estimate_messages_tokens_rough(messages) + saved_estimate = pre_estimate - new_estimate + savings_pct = (saved_estimate / pre_estimate * 100) if pre_estimate > 0 else 0 self._last_compression_savings_pct = savings_pct - if savings_pct < 10: - self._ineffective_compression_count += 1 - else: - self._ineffective_compression_count = 0 + + # Message-only savings are diagnostic. The anti-thrashing verdict is + # owned by the next provider-reported prompt count, which answers the + # actual question: did this completed boundary get under the threshold? + # Counting a low message-savings estimate here as well would give one + # compaction two strikes when that real reading remains over threshold. if not self.quiet_mode: logger.info( @@ -3078,5 +3447,6 @@ This compaction should PRIORITISE preserving all information related to the focu # are positional; this single terminal sweep makes it structural so a # future copy site cannot re-leak the marker into the child-session flush. _strip_persistence_markers(compressed) + self._last_compression_made_progress = True return compressed diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 843960cc2818..6f2c5eb57576 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -28,6 +28,7 @@ these paths see no behavioural change. from __future__ import annotations +import inspect import logging import os import tempfile @@ -52,6 +53,29 @@ COMPACTION_STATUS = ( ) +def _lock_api_is_absent_on_session_db(lock_db: Any) -> bool: + """Whether the live in-memory SessionDB class structurally predates locks. + + In the supported hot-reload skew, this module is new while the already + imported ``hermes_state.SessionDB`` class (and its live instances) is old. + Only that exact class identity may fail open. Proxies, nominal lookalikes, + non-callables, and descriptor failures must fail closed. Static lookup + avoids invoking a present-but-broken descriptor. + """ + try: + from hermes_state import SessionDB + + missing = object() + return ( + type(lock_db) is SessionDB + and inspect.getattr_static( + SessionDB, "try_acquire_compression_lock", missing + ) is missing + ) + except Exception: + return False + + def _compression_lock_holder(agent: Any) -> str: """Build a unique holder id for the lock: pid:tid:agent-instance:uuid. @@ -480,6 +504,21 @@ def compress_context( force=force, ) + # Every automatic entrypoint must honor compressor-owned cooldown and + # breaker state. Gateway hygiene constructs a fresh AIAgent, so the + # persisted fallback streak is loaded by bind_session_state() before this. + if not force: + blocked = getattr( + type(agent.context_compressor), + "_automatic_compression_blocked", + None, + ) + if callable(blocked) and blocked(agent.context_compressor): + existing_prompt = getattr(agent, "_cached_system_prompt", None) + if not existing_prompt: + existing_prompt = agent._build_system_prompt(system_message) + return messages, existing_prompt + # Lazy feasibility check — run the auxiliary-provider probe + context # length lookup just-in-time on the first compression attempt instead of # at AIAgent.__init__. Saves ~400ms cold off every short session that @@ -541,21 +580,31 @@ def compress_context( _lock_sid = agent.session_id or "" _lock_holder: Optional[str] = None # Probe whether the lock subsystem is actually available on this - # SessionDB instance. A process running mismatched module versions - # (e.g. ``conversation_compression.py`` reloaded after a pull but the - # long-lived ``hermes_state.SessionDB`` class still bound to the - # pre-#34351 version in memory) has the call site but not the method. - # In that case ``try_acquire_compression_lock`` raises AttributeError — - # NOT a ``sqlite3.Error`` — so the method's own fail-open guard never - # runs and the exception propagates to the outer agent loop, which - # prints the error and retries. Because compression never succeeds, - # the token count never drops and the loop re-triggers compaction - # forever (the "API call #47/#48/#49 ... has no attribute - # try_acquire_compression_lock" spin). Fail OPEN here: if the lock - # subsystem is missing or broken in any unexpected way, skip locking - # and proceed with compression. Skipping the lock risks a rare - # concurrent-compression session fork; an infinite no-progress loop - # that never compresses at all is strictly worse. + # SessionDB instance. A process running mismatched module versions can have + # this call site while its long-lived SessionDB instance predates the lock + # API. Only that structural absence is safe to fail open for: compression + # must make progress rather than spin forever after an update. Once the + # method has been resolved, every exception from its implementation fails + # closed because proceeding without a lock can fork the session lineage. + _try_acquire_lock = None + _lock_lookup_error: Optional[Exception] = None + _legacy_session_db_without_lock_api = False + if _lock_db is not None: + try: + _legacy_session_db_without_lock_api = _lock_api_is_absent_on_session_db( + _lock_db + ) + except Exception as exc: + _lock_lookup_error = exc + if _lock_lookup_error is None and not _legacy_session_db_without_lock_api: + try: + _try_acquire_lock = _lock_db.try_acquire_compression_lock + if not callable(_try_acquire_lock): + _lock_lookup_error = TypeError( + "compression lock API is present but not callable" + ) + except Exception as exc: + _lock_lookup_error = exc try: _lock_ttl = float(getattr(agent, "_compression_lock_ttl_seconds", 300.0) or 300.0) except (TypeError, ValueError): @@ -564,25 +613,58 @@ def compress_context( _lock_refresher: Optional[_CompressionLockLeaseRefresher] = None if _lock_db is not None and _lock_sid: _lock_holder = _compression_lock_holder(agent) - try: - _lock_acquired = _lock_db.try_acquire_compression_lock( - _lock_sid, _lock_holder, ttl_seconds=_lock_ttl + if _lock_lookup_error is not None: + # Attribute lookup itself failed for a reason other than a missing + # lock API. It is unsafe to proceed without a lock in that case. + _lock_holder = None + logger.warning( + "compression lock lookup raised unexpectedly for session=%s " + "(%s: %s) — skipping compression this cycle", + _lock_sid, type(_lock_lookup_error).__name__, _lock_lookup_error, ) - except Exception as _lock_err: - # Broken/absent lock subsystem (version skew, etc.). Log once - # per session and proceed WITHOUT the lock rather than letting - # the exception spin the outer loop. - _lock_holder = None # we don't own anything to release + _lock_acquired = False + elif _try_acquire_lock is None: + # The lock API itself is absent on this in-memory instance. Log once + # and proceed unlocked so an update-version skew cannot leave the + # outer auto-compression loop making no progress forever. + _lock_holder = None if getattr(agent, "_last_compression_lock_error_sid", None) != _lock_sid: agent._last_compression_lock_error_sid = _lock_sid logger.warning( "compression lock subsystem unavailable for session=%s " - "(%s: %s) — proceeding without lock. This usually means a " - "stale in-memory module after an update; restart the " - "process (or `hermes update`) to resync.", + "— proceeding without lock. This usually means a stale " + "in-memory module after an update; restart the process " + "(or `hermes update`) to resync.", + _lock_sid, + ) + _lock_acquired = True # acquired-but-unlocked compatibility path + else: + try: + _lock_acquired = _try_acquire_lock( + _lock_sid, _lock_holder, ttl_seconds=_lock_ttl + ) + except Exception as _lock_err: + # The method exists and entered its implementation but failed. + # Do not mistake an internal AttributeError or TypeError for + # version skew: fail closed and preserve session lineage. A + # failure after SQLite committed the acquire can leave our + # holder row behind, so release it best-effort before returning + # unchanged messages; release is holder-qualified and safe when + # acquisition never succeeded. + try: + _lock_db.release_compression_lock(_lock_sid, _lock_holder) + except Exception as _release_err: + logger.debug( + "compression lock cleanup after failed acquire failed: %s", + _release_err, + ) + _lock_holder = None + logger.warning( + "compression lock acquisition raised unexpectedly for " + "session=%s (%s: %s) — skipping compression this cycle", _lock_sid, type(_lock_err).__name__, _lock_err, ) - _lock_acquired = True # treat as acquired-but-unlocked; proceed + _lock_acquired = False if not _lock_acquired: try: existing = _lock_db.get_compression_lock_holder(_lock_sid) @@ -651,6 +733,17 @@ def compress_context( _release_lock() raise + # Capture boundary quality before session-rotation callbacks run. Built-in + # and plugin lifecycle hooks may reset per-session compressor fields while + # rebinding to the child id; the completed attempt's verdict must survive + # that rebind and be recorded only after the full boundary commits. + _compression_made_progress = bool( + getattr(agent.context_compressor, "_last_compression_made_progress", False) + ) + _compression_used_fallback = bool( + getattr(agent.context_compressor, "_last_summary_fallback_used", False) + ) + # If compression aborted (aux LLM failed to produce a usable summary) # the compressor returns the input messages unchanged. Surface the # error to the user, skip the session-rotation work entirely (no @@ -673,6 +766,20 @@ def compress_context( finally: _release_lock() + # A compressor that returns the exact input object made no structural + # progress. Do not rotate/rewrite the session or arm post-compression + # deferral in that case; its own anti-thrash counter records the no-op. + if compressed is messages: + logger.info( + "Compression made no progress (session=%s) — skipping boundary rewrite.", + agent.session_id or "none", + ) + _existing_sp = getattr(agent, "_cached_system_prompt", None) + if not _existing_sp: + _existing_sp = agent._build_system_prompt(system_message) + _release_lock() + return messages, _existing_sp + try: summary_error = getattr(agent.context_compressor, "_last_summary_error", None) if summary_error: @@ -961,6 +1068,23 @@ def compress_context( agent.context_compressor.last_prompt_tokens = -1 agent.context_compressor.last_completion_tokens = 0 agent.context_compressor.awaiting_real_usage_after_compression = True + # Arm the effectiveness verdict only after a completed rewrite crosses + # the full compaction boundary. Exceptions, aborts, and no-op attempts + # leave this false, so unrelated later usage cannot be charged to an + # attempt that never changed the transcript. + if _compression_made_progress: + record_boundary = getattr( + type(agent.context_compressor), + "record_completed_compaction", + None, + ) + if callable(record_boundary): + record_boundary( + agent.context_compressor, + used_fallback=_compression_used_fallback, + ) + else: + agent.context_compressor._verify_compaction_cleared_threshold = True # Clear the file-read dedup cache. After compression the original # read content is summarised away — if the model re-reads the same @@ -1054,7 +1178,7 @@ def _compress_context_via_codex_app_server( pass agent._codex_session = None - if getattr(result, "error", None): + if getattr(result, "interrupted", False) or getattr(result, "error", None): try: agent._emit_warning( f"⚠ Codex app-server compaction failed: {result.error}" @@ -1078,7 +1202,11 @@ def _compress_context_via_codex_app_server( approx_tokens=approx_tokens, force=True, ) - if getattr(result, "token_usage_last", None): + # An empty usage report must consume the pending post-compaction verdict + # rather than leaving preflight deferral armed until some unrelated later + # Codex turn supplies usage. Minimal external test engines may not expose + # the ContextEngine update hook; preserve their existing bookkeeping. + if hasattr(agent.context_compressor, "update_from_response"): _record_codex_app_server_usage(agent, result) except Exception: logger.debug("codex compaction bookkeeping failed", exc_info=True) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index cbcb85617013..420d12670e62 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -194,7 +194,6 @@ def _is_nous_inference_route(provider: str, base_url: str) -> bool: base = str(base_url or "") return ( base_url_host_matches(base, "inference-api.nousresearch.com") - or base_url_host_matches(base, "inference.nousresearch.com") ) @@ -458,6 +457,21 @@ def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List ) +# Continuation nudge for Codex/Responses turns that came back with only +# internal reasoning (no visible content, no tool calls). When the interim +# assistant message also carries no encrypted reasoning items and no +# replayable message items, _chat_messages_to_responses_input emits nothing +# for it — a bare retry would be byte-identical to the request that just +# failed, so the model (observed: grok-4.20 on xai-oauth) deterministically +# repeats the reasoning-only response until the retry budget is exhausted. +_CODEX_INCOMPLETE_NUDGE = ( + "[System: Your previous response contained only internal reasoning and " + "never produced a visible answer or tool call. Do not keep thinking. " + "Produce your final answer as plain text now (or make the tool call " + "you were planning).]" +) + + # Shared recovery hint appended to every content-policy refusal message. Both # the HTTP-200 refusal path (``finish_reason=content_filter``) and the # exception path (a provider moderation error classified as @@ -522,12 +536,12 @@ def _sync_failover_system_message(agent, api_messages, active_system_prompt): def run_conversation( agent, - user_message: str, + user_message: Any, system_message: str = None, conversation_history: List[Dict[str, Any]] = None, task_id: str = None, stream_callback: Optional[callable] = None, - persist_user_message: Optional[str] = None, + persist_user_message: Optional[Any] = None, persist_user_timestamp: Optional[float] = None, moa_config: Optional[dict[str, Any]] = None, ) -> Dict[str, Any]: @@ -613,6 +627,11 @@ def run_conversation( truncated_response_parts: List[str] = [] compression_attempts = 0 _turn_exit_reason = "unknown" # Diagnostic: why the loop ended + # Last composed answer intentionally held back by a verification gate. If + # that continuation consumes the remaining budget, this is the best + # user-facing result available; it must not be confused with error or + # recovery text produced by unrelated exit paths. + _pending_verification_response = None # Per-turn tally of consecutive successful credential-pool token refreshes, # keyed by (provider, pool-entry-id). A persistent upstream 401 lets @@ -852,10 +871,19 @@ def run_conversation( if moa_config: try: + from agent.message_content import flatten_message_text as _flatten_mt from agent.moa_loop import _preset_temperature, aggregate_moa_context _moa_context = aggregate_moa_context( - user_prompt=original_user_message if isinstance(original_user_message, str) else str(original_user_message), + user_prompt=( + original_user_message + if isinstance(original_user_message, str) + # Multimodal / decorated content list: extract the + # visible text instead of str()-ing a Python repr of + # the parts (which would leak base64 image payloads + # into the aggregator prompt). + else _flatten_mt(original_user_message) + ), api_messages=api_messages, reference_models=moa_config.get("reference_models") or [], aggregator=moa_config.get("aggregator") or {}, @@ -869,6 +897,14 @@ def run_conversation( _base = _msg.get("content", "") if isinstance(_base, str): _msg["content"] = _base + "\n\n" + _moa_context + elif isinstance(_base, list): + # Multimodal user turn (text + image parts): + # append the MoA context as a trailing text + # part instead of silently dropping it. + _msg["content"] = [ + *_base, + {"type": "text", "text": "\n\n" + _moa_context}, + ] break except Exception as _moa_exc: logger.warning("MoA context aggregation failed: %s", _moa_exc) @@ -1162,7 +1198,11 @@ def run_conversation( if agent._force_ascii_payload: _sanitize_structure_non_ascii(api_kwargs) if agent.api_mode == "codex_responses": - api_kwargs = agent._get_transport().preflight_kwargs(api_kwargs, allow_stream=False) + api_kwargs = agent._get_transport().preflight_kwargs( + api_kwargs, + allow_stream=False, + is_github_responses=agent._is_copilot_url(), + ) # Copilot x-initiator: the first API call of a user turn is # marked "user" so Copilot bills a premium request; tool-loop # follow-ups keep the default "agent" header (#3040). @@ -1309,6 +1349,12 @@ def run_conversation( _use_streaming = False def _perform_api_call(next_api_kwargs): + if agent.api_mode == "codex_responses": + next_api_kwargs = agent._get_transport().preflight_kwargs( + next_api_kwargs, + allow_stream=False, + is_github_responses=agent._is_copilot_url(), + ) if _use_streaming: return agent._interruptible_streaming_api_call( next_api_kwargs, on_first_delta=_stop_spinner @@ -1596,12 +1642,16 @@ def run_conversation( # Check finish_reason before proceeding if agent.api_mode == "codex_responses": status = getattr(response, "status", None) + if isinstance(status, str): + status = status.strip().lower() incomplete_details = getattr(response, "incomplete_details", None) incomplete_reason = None if isinstance(incomplete_details, dict): incomplete_reason = incomplete_details.get("reason") else: incomplete_reason = getattr(incomplete_details, "reason", None) + if incomplete_reason is not None: + incomplete_reason = str(incomplete_reason).strip().lower() if status == "incomplete" and incomplete_reason in {"max_output_tokens", "length"}: # Responses API max-output exhaustion is a normal # Codex incomplete turn. Let the Codex-specific @@ -1611,6 +1661,8 @@ def run_conversation( # emits "Response truncated due to output length # limit" and stops gateway turns. finish_reason = "incomplete" + elif status == "incomplete" and incomplete_reason == "content_filter": + finish_reason = "content_filter" else: finish_reason = "stop" elif agent.api_mode == "anthropic_messages": @@ -2104,7 +2156,19 @@ def run_conversation( "reasoning_tokens": canonical_usage.reasoning_tokens, } agent.context_compressor.update_from_response(usage_dict) + elif getattr( + agent.context_compressor, + "awaiting_real_usage_after_compression", + False, + ): + # A response with no usage cannot adjudicate whether the + # prior compaction cleared the threshold. Consume the pending + # verdict now so a much later, unrelated reading is not + # charged to that old compaction, and so preflight deferral + # does not remain latched indefinitely. + agent.context_compressor.update_from_response({}) + if hasattr(response, 'usage') and response.usage: # Cache discovered context length after successful call. # Only persist limits confirmed by the provider (parsed # from the error message), not guessed probe tiers. @@ -2561,6 +2625,31 @@ def run_conversation( ) continue + # ── Bedrock AnthropicBedrock SDK streaming failure ── + # The Anthropic SDK's stream accumulator raises RuntimeError + # "Unexpected event order" when Bedrock returns an error event + # before message_start (throttling, overload, validation). + # Fall back to the native Converse API path for the rest of + # this session — it handles these errors gracefully. Ref: #28156. + if ( + isinstance(api_error, RuntimeError) + and "unexpected event order" in str(api_error).lower() + and getattr(agent, "provider", "") == "bedrock" + and agent.api_mode == "anthropic_messages" + and not getattr(agent, "_bedrock_converse_fallback_attempted", False) + ): + agent._bedrock_converse_fallback_attempted = True + agent.api_mode = "bedrock_converse" + agent._bedrock_region = getattr(agent, "_bedrock_region", None) or "us-east-1" + agent.client = None # Drop the AnthropicBedrock client + agent._client_kwargs = {} + agent._vprint( + f"{agent.log_prefix}⚠️ AnthropicBedrock SDK streaming failed — " + f"falling back to native Converse API for this session.", + force=True, + ) + continue + status_code = getattr(api_error, "status_code", None) error_context = agent._extract_api_error_context(api_error) @@ -3034,14 +3123,24 @@ def run_conversation( # (``/new``), switch to a larger-context model, or reduce # attachments. Forced compaction via ``/compress`` # (``force=True``) is unaffected — it never reaches this loop. + # + # Output-cap errors (max_tokens too large) are NOT input + # overflow — the recovery is a max_tokens-only retry that + # does not require compression. Exempt them from this guard + # so the retry still fires even when compression is disabled. _overflow_reasons = { FailoverReason.long_context_tier, FailoverReason.payload_too_large, FailoverReason.context_overflow, } + _is_output_cap_error = ( + is_output_cap_error(error_msg) + or parse_available_output_tokens_from_error(error_msg) is not None + ) if ( classified.reason in _overflow_reasons and not getattr(agent, "compression_enabled", True) + and not _is_output_cap_error ): agent._flush_status_buffer() agent._vprint( @@ -3445,15 +3544,33 @@ def run_conversation( # context_length = total window (input + output combined). available_out = parse_available_output_tokens_from_error(error_msg) if available_out is not None: - # Error is purely about the output cap being too large. - # Cap output to the available space and retry without - # touching context_length or triggering compression. - safe_out = max(1, available_out - 64) # small safety margin + # This is an output-cap error, not input overflow. + # The provider's available_tokens is the authoritative + # cap for the failed request, so keep it as an upper + # bound. Also estimate the current API request shape + # (system prompt, injected context, tool schemas) because + # Hermes may add API-only content not present in persisted + # messages. Use the smaller budget and apply a small + # safety margin. Do not alter context_length. + request_input_estimate = estimate_request_tokens_rough( + api_messages, tools=agent.tools or None, + ) + local_available_out = old_ctx - request_input_estimate + if local_available_out > 0: + safe_out = max(1, min(available_out, local_available_out) - 64) + else: + # The rough local estimate can overshoot the real + # request size. Fall back to the provider-reported + # budget, which is authoritative for the failed + # request. + safe_out = max(1, available_out - 64) agent._ephemeral_max_output_tokens = safe_out agent._buffer_vprint( f"⚠️ Output cap too large for current prompt — " f"retrying with max_tokens={safe_out:,} " - f"(available_tokens={available_out:,}; context_length unchanged at {old_ctx:,})" + f"(provider_available={available_out:,}, " + f"estimated_request_tokens={request_input_estimate:,}; " + f"context_length unchanged at {old_ctx:,})" ) # Still count against compression_attempts so we don't # loop forever if the error keeps recurring. @@ -4373,33 +4490,82 @@ def run_conversation( or interim_has_codex_message_items ): last_msg = messages[-1] if messages else None - # Duplicate detection: two consecutive incomplete assistant - # messages with identical content AND reasoning are collapsed. - # For provider-state-only changes (encrypted reasoning - # items or replayable message ids/phases/statuses differ - # while visible content/reasoning are unchanged), compare - # those opaque payloads too so we don't silently drop the - # newer continuation state. - last_codex_items = last_msg.get("codex_reasoning_items") if isinstance(last_msg, dict) else None - interim_codex_items = interim_msg.get("codex_reasoning_items") - last_codex_message_items = last_msg.get("codex_message_items") if isinstance(last_msg, dict) else None - interim_codex_message_items = interim_msg.get("codex_message_items") - duplicate_interim = ( + # Duplicate detection: compare only visible content + # (content + reasoning). Opaque provider state + # (encrypted reasoning items, message item ids/phases) + # drifts per continuation even when the visible output + # is identical, so including it in the comparison defeats + # dedup and causes message storms (#52711). + visible_duplicate = ( isinstance(last_msg, dict) and last_msg.get("role") == "assistant" and last_msg.get("finish_reason") == "incomplete" and (last_msg.get("content") or "") == (interim_msg.get("content") or "") and (last_msg.get("reasoning") or "") == (interim_msg.get("reasoning") or "") - and last_codex_items == interim_codex_items - and last_codex_message_items == interim_codex_message_items ) - if not duplicate_interim: + if visible_duplicate: + # Update opaque state in-place so the latest + # provider payload is preserved without emitting + # a duplicate visible message. + for _key in ("codex_reasoning_items", "codex_message_items"): + _new_val = interim_msg.get(_key) + if _new_val is not None: + last_msg[_key] = _new_val + else: messages.append(interim_msg) agent._emit_interim_assistant_message(interim_msg) if agent._codex_incomplete_retries < 3: + # When the interim message has nothing the Responses + # input converter will replay (no visible content, no + # encrypted reasoning items, no replayable message + # items — plain-text reasoning only), a bare retry is + # byte-identical to the request that just came back + # incomplete and fails the same way every time + # (observed with grok-4.20 on xai-oauth, whose + # reasoning items lack encrypted_content). Append a + # user-role nudge so the retry actually differs and + # explicitly asks for the final answer. + interim_replayable = ( + interim_has_content + or interim_has_codex_reasoning + or interim_has_codex_message_items + ) + if not interim_replayable: + _last_msg = messages[-1] if messages else None + _already_nudged = ( + isinstance(_last_msg, dict) + and _last_msg.get("role") == "user" + and _last_msg.get("content") == _CODEX_INCOMPLETE_NUDGE + ) + # Alternation guard: the nudge is a user-role message, + # so it may only follow an assistant message. When the + # interim was too empty to append (no content AND no + # reasoning), the last message is still the prior + # user/tool turn — appending the nudge there would + # create a user→user / tool→user sequence that strict + # providers reject. + _last_is_assistant = ( + isinstance(_last_msg, dict) + and _last_msg.get("role") == "assistant" + ) + if not _already_nudged and _last_is_assistant: + messages.append({ + "role": "user", + "content": _CODEX_INCOMPLETE_NUDGE, + }) if not agent.quiet_mode: agent._vprint(f"{agent.log_prefix}↻ Codex response incomplete; continuing turn ({agent._codex_incomplete_retries}/3)") + # Surface the continuation on the live spinner/status line + # (CLI/TUI/Desktop) and gateway heartbeat: each of these + # retries can spend minutes waiting on the provider, and + # without a distinct notice the user only sees a generic + # thinking spinner ("infinite thinking", #64434). + agent._emit_wait_notice( + f"↻ model returned reasoning with no final answer — " + f"asking it to continue " + f"({agent._codex_incomplete_retries}/3)" + ) agent._session_messages = messages continue @@ -4423,7 +4589,9 @@ def run_conversation( if agent.verbose_logging: for tc in assistant_message.tool_calls: - logging.debug(f"Tool call: {tc.function.name} with args: {tc.function.arguments[:200]}...") + raw_args = tc.function.arguments + args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200] + logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview) # Validate tool call names - detect model hallucinations # Repair mismatched tool names before validating @@ -4604,11 +4772,37 @@ def run_conversation( assistant_msg = agent._build_assistant_message(assistant_message, finish_reason) + turn_content = assistant_message.content or "" + + # Classify tools in this turn to determine if they are all housekeeping. + # This classification is needed regardless of whether the turn has visible content, + # because a substantive tool-only turn must invalidate any older housekeeping fallback. + _HOUSEKEEPING_TOOLS = frozenset({ + "memory", "todo", "skill_manage", "session_search", + }) + _all_housekeeping = all( + tc.function.name in _HOUSEKEEPING_TOOLS + for tc in assistant_message.tool_calls + ) + + # If this turn has substantive tools (non-housekeeping), clear any older fallback. + # Prevents a two-turn-old housekeeping narration from being treated as if it belonged + # to the immediately preceding substantive tool turn. + if assistant_message.tool_calls and not _all_housekeeping: + agent._last_content_with_tools = None + agent._last_content_tools_all_housekeeping = False + # Also clear the mute flag: a prior housekeeping turn may + # have set _mute_post_response (line ~4667), and the + # substantive tools in THIS turn should produce visible + # progress output. Without this reset, _vprint suppresses + # tool progress until the no-tool-call branch clears it at + # line ~4834 — after all tools have finished. + agent._mute_post_response = False + # If this turn has both content AND tool_calls, capture the content # as a fallback final response. Common pattern: model delivers its # answer and calls memory/skill tools as a side-effect in the same # turn. If the follow-up turn after tools is empty, we use this. - turn_content = assistant_message.content or "" if turn_content and agent._has_content_after_think_block(turn_content): agent._last_content_with_tools = turn_content # Only mute subsequent output when EVERY tool call in @@ -4616,13 +4810,6 @@ def run_conversation( # skill_manage, etc.). If any substantive tool is present # (search_files, read_file, write_file, terminal, ...), # keep output visible so the user sees progress. - _HOUSEKEEPING_TOOLS = frozenset({ - "memory", "todo", "skill_manage", "session_search", - }) - _all_housekeeping = all( - tc.function.name in _HOUSEKEEPING_TOOLS - for tc in assistant_message.tool_calls - ) agent._last_content_tools_all_housekeeping = _all_housekeeping if _all_housekeeping and agent._has_stream_consumers(): agent._mute_post_response = True @@ -5062,8 +5249,11 @@ def run_conversation( # Reset retry counter/signature on successful content agent._empty_content_retries = 0 agent._thinking_prefill_retries = 0 - # Successful content reached — drop any buffered retry - # status from earlier failed attempts in this turn. + # Successful content reached — surface the one-shot fallback + # switch notice (if a fallback activated this turn) before + # dropping the noisy retry buffer, so a provider/model switch + # stays visible even when the fallback succeeds. + agent._emit_pending_fallback_notice() agent._clear_status_buffer() from agent.agent_runtime_helpers import ( @@ -5096,6 +5286,10 @@ def run_conversation( } messages.append(continue_msg) agent._session_messages = messages + # An acknowledgment is explicitly non-final. Do not let its + # text suppress iteration-limit summarization if this + # continuation consumes the remaining budget. + final_response = None continue codex_ack_continuations = 0 @@ -5170,6 +5364,12 @@ def run_conversation( # terminal. Keep a debug breadcrumb in agent.log for tracing. logger.debug("verification stop-loop nudge issued (attempt %d)", agent._verification_stop_nudges) + # Keep the attempted answer only as an explicit fallback for + # continuation-budget exhaustion. ``final_response`` itself + # must be cleared so the finalizer can distinguish this gate + # from unrelated error/recovery exits. (#61631) + _pending_verification_response = final_response + final_response = None continue # User verification-loop gate: when the agent edited code this @@ -5221,6 +5421,55 @@ def run_conversation( agent._session_messages = messages logger.debug("pre_verify nudge issued (attempt %d)", agent._pre_verify_nudges) + _pending_verification_response = final_response + final_response = None + continue + + # ── Kanban worker terminal-tool stop guard ───────────── + # Workers must end with kanban_complete / kanban_block. + # Models sometimes narrate the next step ("Let me write the + # report") and stop with finish_reason=stop — a clean exit + # that the dispatcher records as protocol_violation. Nudge + # once or twice before allowing that exit. + try: + from agent.kanban_stop import build_kanban_stop_nudge + + _kanban_nudge = build_kanban_stop_nudge( + messages=messages, + attempts=getattr(agent, "_kanban_stop_nudges", 0), + ) + except Exception: + logger.debug("kanban stop-loop check failed", exc_info=True) + _kanban_nudge = None + + if _kanban_nudge: + agent._kanban_stop_nudges = ( + getattr(agent, "_kanban_stop_nudges", 0) + 1 + ) + final_msg["finish_reason"] = "kanban_terminal_required" + final_msg["_kanban_stop_synthetic"] = True + messages.append(final_msg) + messages.append({ + "role": "user", + "content": _kanban_nudge, + "_kanban_stop_synthetic": True, + }) + agent._session_messages = messages + logger.info( + "kanban stop-loop nudge issued (attempt %d) task=%s", + agent._kanban_stop_nudges, + os.environ.get("HERMES_KANBAN_TASK", ""), + ) + agent._emit_status( + "⚠️ Kanban worker tried to exit without " + "kanban_complete/kanban_block — nudging to finish" + ) + # Same finalizer contract as verify-on-stop: clear + # final_response while continuing so a later budget + # exhaustion path does not treat the narrated stop as + # a completed answer. + _pending_verification_response = final_response + final_response = None continue messages.append(final_msg) @@ -5305,6 +5554,7 @@ def run_conversation( original_user_message=original_user_message, _should_review_memory=_should_review_memory, _turn_exit_reason=_turn_exit_reason, + _pending_verification_response=_pending_verification_response, ) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index ce3ec2c5c400..5e095af3902b 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -26,7 +26,7 @@ from openai.types.chat.chat_completion_message_tool_call import ( Function, ) -from agent.file_safety import get_read_block_error, is_write_denied +from agent.file_safety import get_read_block_error, get_write_denied_error from agent.redact import redact_sensitive_text from tools.environments.local import hermes_subprocess_env @@ -727,10 +727,9 @@ class CopilotACPClient: elif method == "fs/write_text_file": try: path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) - if is_write_denied(str(path)): - raise PermissionError( - f"Write denied: '{path}' is a protected system/credential file." - ) + denied = get_write_denied_error(str(path)) + if denied: + raise PermissionError(denied) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(str(params.get("content") or "")) response = { diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 9d5d81b2386f..3bd59493508c 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -128,6 +128,17 @@ _EXTRA_KEYS = frozenset({ }) +def _normalize_pool_auth_type(provider: str, token: Any, auth_type: Any) -> str: + """Infer pool auth metadata for token formats with one unambiguous meaning.""" + if ( + provider == "anthropic" + and isinstance(token, str) + and token.startswith("sk-ant-oat") + ): + return AUTH_TYPE_OAUTH + return str(auth_type or AUTH_TYPE_API_KEY) + + @dataclass class PooledCredential: provider: str @@ -157,6 +168,11 @@ class PooledCredential: def __post_init__(self): if self.extra is None: self.extra = {} + self.auth_type = _normalize_pool_auth_type( + self.provider, + self.access_token, + self.auth_type, + ) def __getattr__(self, name: str): if name in _EXTRA_KEYS: @@ -445,6 +461,44 @@ def get_pool_strategy(provider: str) -> str: return STRATEGY_FILL_FIRST +def credential_pool_matches_provider( + pool_or_provider: Any, + provider: Optional[str], + *, + base_url: Optional[str] = None, +) -> bool: + """Return whether a pool belongs to the requested runtime provider. + + Named custom endpoints intentionally use two identities: the live agent is + ``custom`` while its pool is keyed ``custom:``. Accept that pair only + when the runtime base URL resolves to the exact same custom pool key. + Empty string identities fail closed. Legacy pool adapters without a + ``provider`` attribute remain compatible; production pools are scoped. + """ + raw_pool_provider = getattr(pool_or_provider, "provider", None) + if raw_pool_provider is None: + if isinstance(pool_or_provider, str): + raw_pool_provider = pool_or_provider + else: + # Backward compatibility for lightweight/unscoped pool adapters. + # Production CredentialPool instances always carry ``provider``; + # old plugins and tests may expose only select()/has_credentials(). + return True + pool_provider = str(raw_pool_provider or "").strip().lower() + provider_norm = str(provider or "").strip().lower() + if not pool_provider or not provider_norm: + return False + if pool_provider == provider_norm: + return True + if provider_norm != "custom" or not pool_provider.startswith(CUSTOM_POOL_PREFIX): + return False + try: + matched_pool = get_custom_provider_pool_key(base_url or "") + except Exception: + return False + return str(matched_pool or "").strip().lower() == pool_provider + + DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1 @@ -1378,6 +1432,11 @@ class CredentialPool: entries_to_prune: List[str] = [] available: List[PooledCredential] = [] for entry in self._entries: + # Borrowed credentials persist as metadata-only references and are + # hydrated from their live source on load. A stale duplicate row + # can remain unhydrated; never lease or select it as an empty key. + if entry.auth_type == AUTH_TYPE_API_KEY and not entry.runtime_api_key: + continue # For anthropic claude_code entries, sync from the credentials file # before any status/refresh checks. This picks up tokens refreshed # by other processes (Claude Code CLI, other Hermes profiles). @@ -1694,11 +1753,15 @@ class CredentialPool: def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool: - existing_idx = None + matching_indices = [] for idx, entry in enumerate(entries): if entry.source == source: - existing_idx = idx - break + matching_indices.append(idx) + + existing_idx = matching_indices[0] if matching_indices else None + duplicate_indices = set(matching_indices[1:]) + if duplicate_indices: + entries[:] = [entry for idx, entry in enumerate(entries) if idx not in duplicate_indices] if existing_idx is None: payload.setdefault("id", uuid.uuid4().hex[:6]) @@ -1730,8 +1793,8 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p # Runtime-only borrowed secret updates should refresh the in-memory # entry without forcing auth.json churn when the disk-safe payload is # unchanged (for example env keys with the same fingerprint). - return existing.to_dict() != updated.to_dict() - return False + return bool(duplicate_indices) or existing.to_dict() != updated.to_dict() + return bool(duplicate_indices) def _normalize_pool_priorities(provider: str, entries: List[PooledCredential]) -> bool: @@ -2205,12 +2268,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool if _is_source_suppressed(provider, source): continue active_sources.add(source) - # Claude Code OAuth tokens are the only Anthropic credentials that should flow into the OAuth refresh path. - auth_type = ( - AUTH_TYPE_OAUTH - if provider == "anthropic" and token.startswith("sk-ant-oat") - else AUTH_TYPE_API_KEY - ) base_url = env_url or pconfig.inference_base_url if provider == "kimi-coding": base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url) @@ -2225,7 +2282,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool env_var=env_var, token=token, base_url=base_url, - auth_type=auth_type, ), ) return changed, active_sources @@ -2353,16 +2409,37 @@ def load_pool(provider: str) -> CredentialPool: for payload in raw_entries ) entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries] + raw_needs_auth_normalization = any( + isinstance(payload, dict) + and _normalize_pool_auth_type( + provider, + payload.get("access_token"), + payload.get("auth_type", AUTH_TYPE_API_KEY), + ) != payload.get("auth_type", AUTH_TYPE_API_KEY) + for payload in raw_entries + ) + if raw_needs_auth_normalization: + # A profile may be reading this provider from the global-root fallback. + # Keep that fallback read-only: only the store that owns these rows may + # rewrite them. Loading the default/root profile will heal global rows. + active_pool = _load_auth_store().get("credential_pool") + active_entries = active_pool.get(provider) if isinstance(active_pool, dict) else None + raw_needs_auth_normalization = bool(active_entries) if provider.startswith(CUSTOM_POOL_PREFIX): # Custom endpoint pool — seed from custom_providers config and model config custom_changed, custom_sources = _seed_custom_pool(provider, entries) - changed = raw_needs_sanitization or custom_changed + changed = raw_needs_sanitization or raw_needs_auth_normalization or custom_changed changed |= _prune_stale_seeded_entries(entries, custom_sources) else: singleton_changed, singleton_sources = _seed_from_singletons(provider, entries) env_changed, env_sources = _seed_from_env(provider, entries) - changed = raw_needs_sanitization or singleton_changed or env_changed + changed = ( + raw_needs_sanitization + or raw_needs_auth_normalization + or singleton_changed + or env_changed + ) # ``load_pool()`` is a non-destructive read for env-seeded entries: a # process missing a provider env var must not delete the persisted # pool entry for every other process (#9331). File-backed singletons diff --git a/agent/curator.py b/agent/curator.py index c13a36ecbbd3..ada93248e246 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -45,12 +45,26 @@ def _strip_aux_credential(value: Any) -> Optional[str]: class _ReviewRuntimeBinding(NamedTuple): - """Provider/model for the curator review fork plus optional per-slot overrides.""" + """Provider/model for the curator review fork plus per-slot overrides.""" provider: str model: str explicit_api_key: Optional[str] explicit_base_url: Optional[str] + request_overrides: Dict[str, Any] + + +def _merge_request_overrides( + runtime_overrides: Any, + slot_extra_body: Any, +) -> Dict[str, Any]: + """Merge resolver metadata with task-local request body fields.""" + merged = dict(runtime_overrides or {}) + if isinstance(slot_extra_body, dict) and slot_extra_body: + extra_body = dict(merged.get("extra_body") or {}) + extra_body.update(slot_extra_body) + merged["extra_body"] = extra_body + return merged DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days @@ -1764,6 +1778,7 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding: _task_model, _strip_aux_credential(_cur_task.get("api_key")), _strip_aux_credential(_cur_task.get("base_url")), + _merge_request_overrides({}, _cur_task.get("extra_body")), ) # 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification) @@ -1781,10 +1796,11 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding: str(_legacy_model), _strip_aux_credential(_legacy.get("api_key")), _strip_aux_credential(_legacy.get("base_url")), + _merge_request_overrides({}, _legacy.get("extra_body")), ) # 3. Fall through to the main chat model - return _ReviewRuntimeBinding(_main_provider, _main_model, None, None) + return _ReviewRuntimeBinding(_main_provider, _main_model, None, None, {}) def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]: @@ -1850,6 +1866,11 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]: _base_url = None _api_mode = None _resolved_provider = None + _credential_pool = None + _request_overrides: Dict[str, Any] = {} + _max_tokens = None + _acp_command = None + _acp_args = None _model_name = "" try: from hermes_cli.config import load_config @@ -1867,6 +1888,16 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]: _base_url = _rp.get("base_url") _api_mode = _rp.get("api_mode") _resolved_provider = _rp.get("provider") or _provider + _credential_pool = _rp.get("credential_pool") + _request_overrides = _merge_request_overrides( + _rp.get("request_overrides"), + _binding.request_overrides.get("extra_body"), + ) + _max_tokens = _rp.get("max_output_tokens") + _acp_command = _rp.get("command") + _acp_args = list(_rp.get("args") or []) + if isinstance(_rp.get("model"), str) and _rp["model"].strip(): + _model_name = _rp["model"].strip() except Exception as e: logger.debug("Curator provider resolution failed: %s", e, exc_info=True) @@ -1875,12 +1906,21 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]: review_agent = None try: + _agent_kwargs: Dict[str, Any] = {} + if isinstance(_max_tokens, int): + _agent_kwargs["max_tokens"] = _max_tokens + if isinstance(_acp_command, str) and _acp_command: + _agent_kwargs["acp_command"] = _acp_command + _agent_kwargs["acp_args"] = _acp_args or [] review_agent = AIAgent( model=_model_name, provider=_resolved_provider, api_key=_api_key, base_url=_base_url, api_mode=_api_mode, + credential_pool=_credential_pool, + request_overrides=_request_overrides, + **_agent_kwargs, # Umbrella-building over a large skill collection is worth a # high iteration ceiling — the pass typically takes 50-100 # API calls against hundreds of candidate skills. The diff --git a/agent/display.py b/agent/display.py index 5a16a77d00de..27aff5f9d955 100644 --- a/agent/display.py +++ b/agent/display.py @@ -27,6 +27,14 @@ logger = logging.getLogger(__name__) _ANSI_RESET = "\033[0m" + +def _display_url(value: Any) -> str: + """Extract a display-only URL without assuming model argument types.""" + if isinstance(value, dict): + value = value.get("url") or value.get("href") + return value.strip() if isinstance(value, str) else "" + + # Diff colors — resolved lazily from the skin engine so they adapt # to light/dark themes. Falls back to sensible defaults on import # failure. We cache after first resolution for performance. @@ -454,13 +462,14 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - sid = args.get("session_id", "") data = args.get("data", "") timeout_val = args.get("timeout") - parts = [action] + parts = [str(action) if action else ""] if sid: - parts.append(sid[:16]) + parts.append(str(sid)[:16]) if data: - parts.append(f'"{_oneline(data[:20])}"') + parts.append(f'"{_oneline(str(data)[:20])}"') if timeout_val and action == "wait": parts.append(f"{timeout_val}s") + parts = [p for p in parts if p] return " ".join(parts) if parts else None if tool_name == "todo": @@ -1259,7 +1268,7 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str] return False, "" -def get_cute_tool_message( +def _get_cute_tool_message( tool_name: str, args: dict, duration: float, result: str | None = None, ) -> str: """Generate a formatted tool completion line for CLI quiet mode. @@ -1301,9 +1310,11 @@ def get_cute_tool_message( if tool_name == "web_extract": urls = args.get("urls", []) if urls: - url = urls[0] if isinstance(urls, list) else str(urls) + url = _display_url(urls[0] if isinstance(urls, list) else urls) + if not url: + return _wrap(f"┊ 📄 fetch pages {dur}") domain = url.replace("https://", "").replace("http://", "").split("/")[0] - extra = f" +{len(urls)-1}" if len(urls) > 1 else "" + extra = f" +{len(urls)-1}" if isinstance(urls, list) and len(urls) > 1 else "" return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}") return _wrap(f"┊ 📄 fetch pages {dur}") if tool_name == "terminal": @@ -1433,6 +1444,19 @@ def get_cute_tool_message( return _wrap(f"┊ ⚡ {tool_name[:9]:9} {_trunc(preview, 35)} {dur}") +def get_cute_tool_message( + tool_name: str, args: dict, duration: float, result: str | None = None, +) -> str: + """Render a completion label without letting cosmetic failures escape.""" + try: + return _get_cute_tool_message(tool_name, args, duration, result=result) + except Exception as exc: # noqa: BLE001 — display must never abort a turn + logger.debug("Tool completion label failed for %s: %s", tool_name, exc) + safe_name = tool_name[:9] if isinstance(tool_name, str) and tool_name else "tool" + safe_duration = f"{duration:.1f}s" if isinstance(duration, (int, float)) else "done" + return f"┊ ⚡ {safe_name:9} completed {safe_duration}" + + # ========================================================================= # Honcho session line (one-liner with clickable OSC 8 hyperlink) # ========================================================================= diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 4d75502dab42..6afdf4e076e0 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -840,12 +840,34 @@ def classify_api_error( ) return _result(FailoverReason.timeout, retryable=True) - # ── 7. Transport / timeout heuristics ─────────────────────────── + # ── 7b. Stale-call circuit breaker → failover immediately ────── + # _check_stale_giveup() in agent/chat_completion_helpers.py raises a + # RuntimeError when the provider has been unresponsive for N + # consecutive stale attempts (default 5). The error is NOT a transport + # timeout — the circuit breaker fires *before* any network call to avoid + # an indefinite stall. Without this classification the RuntimeError + # falls through to FailoverReason.unknown (retryable=True), which burns + # all max_retries against the same dead provider (each retry hitting the + # circuit breaker instantly with zero network overhead) before fallback + # is attempted. Classify as non-retryable + should_fallback so the + # retry loop activates the next fallback provider on the first hit. + if ( + error_type == "RuntimeError" + and "consecutive stale attempts" in error_msg + and "aborting this call" in error_msg + ): + return _result( + FailoverReason.timeout, + retryable=False, + should_fallback=True, + ) + + # ── 8. Transport / timeout heuristics ─────────────────────────── if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)): return _result(FailoverReason.timeout, retryable=True) - # ── 8. Fallback: unknown ──────────────────────────────────────── + # ── 9. Fallback: unknown ──────────────────────────────────────── return _result(FailoverReason.unknown, retryable=True) @@ -1147,6 +1169,7 @@ def _classify_400( "encrypted content for item" in error_msg and "could not be verified" in error_msg ) + or "could not decrypt the provided encrypted_content" in error_msg ): return result_fn( FailoverReason.invalid_encrypted_content, diff --git a/agent/errors.py b/agent/errors.py index abedd83d29ff..89afcd3b4c8a 100644 --- a/agent/errors.py +++ b/agent/errors.py @@ -1,3 +1,9 @@ class SSLConfigurationError(Exception): """Raised when SSL/TLS certificate bundle configuration fails.""" pass + + +class EmptyStreamError(RuntimeError): + """Raised when a provider closes a stream without yielding a response.""" + + pass diff --git a/agent/file_safety.py b/agent/file_safety.py index d7e20ee5f0b9..2f4b3666e28d 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -95,16 +95,16 @@ def get_safe_write_roots() -> set[str]: return roots -def is_write_denied(path: str) -> bool: - """Return True if path is blocked by the write denylist or safe root.""" +def _classify_write_denial(path: str) -> Optional[str]: + """Return ``'credential'``, ``'safe_root'``, or ``None`` if writes are allowed.""" home = os.path.realpath(os.path.expanduser("~")) resolved = os.path.realpath(os.path.expanduser(str(path))) if resolved in build_write_denied_paths(home): - return True + return "credential" for prefix in build_write_denied_prefixes(home): if resolved.startswith(prefix): - return True + return "credential" mcp_tokens_dir_name = "mcp-tokens" @@ -121,13 +121,13 @@ def is_write_denied(path: str) -> bool: try: mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name)) if resolved == mcp_real or resolved.startswith(mcp_real + os.sep): - return True + return "credential" except Exception: pass try: pairing_real = os.path.realpath(os.path.join(base_real, "pairing")) if resolved == pairing_real or resolved.startswith(pairing_real + os.sep): - return True + return "credential" except Exception: pass @@ -139,9 +139,28 @@ def is_write_denied(path: str) -> bool: allowed = True break if not allowed: - return True + return "safe_root" - return False + return None + + +def is_write_denied(path: str) -> bool: + """Return True if path is blocked by the write denylist or safe root.""" + return _classify_write_denial(path) is not None + + +def get_write_denied_error(path: str, *, verb: str = "Write") -> Optional[str]: + """Return a user/model-facing error when writes to ``path`` are blocked.""" + denial = _classify_write_denial(path) + if denial is None: + return None + if denial == "safe_root": + roots_display = os.pathsep.join(sorted(get_safe_write_roots())) + return ( + f"{verb} denied: '{path}' is outside HERMES_WRITE_SAFE_ROOT " + f"({roots_display}). Unset the variable or add this path's directory prefix." + ) + return f"{verb} denied: '{path}' is a protected system/credential file." # Common secret-bearing project-local environment file basenames. diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 9d3b1eb324d5..1c25f1e6cf0a 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -32,6 +32,13 @@ from agent.gemini_schema import sanitize_gemini_tool_parameters logger = logging.getLogger(__name__) +try: + import hermes_cli as _hermes_cli + + _HERMES_VERSION = str(_hermes_cli.__version__) +except Exception: + _HERMES_VERSION = "0.0.0" + DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" # Published max output-token ceiling shared by every current Gemini text model @@ -99,7 +106,10 @@ def probe_gemini_tier( url, params={"key": key}, json=payload, - headers={"Content-Type": "application/json"}, + headers={ + "Content-Type": "application/json", + "X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}", + }, ) except Exception as exc: logger.debug("probe_gemini_tier: network error: %s", exc) @@ -901,7 +911,11 @@ class GeminiNativeClient: "Content-Type": "application/json", "Accept": "application/json", "x-goog-api-key": self.api_key, - "User-Agent": "hermes-agent (gemini-native)", + # Include Hermes client context following Gemini's partner + # integration guidance. + # See https://ai.google.dev/gemini-api/docs/partner-integration + "User-Agent": f"hermes-agent/{_HERMES_VERSION} (gemini-native)", + "X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}", } headers.update(self._default_headers) return headers diff --git a/agent/insights.py b/agent/insights.py index 9977010549cb..6adba7011354 100644 --- a/agent/insights.py +++ b/agent/insights.py @@ -17,6 +17,7 @@ Usage: """ import json +import sqlite3 import time from collections import Counter, defaultdict from datetime import datetime @@ -141,8 +142,8 @@ class InsightsEngine: } # Compute insights - overview = self._compute_overview(sessions, message_stats) - models = self._compute_model_breakdown(sessions) + models = self._compute_model_breakdown(sessions, cutoff, source) + overview = self._compute_overview(sessions, message_stats, models) platforms = self._compute_platform_breakdown(sessions) tools = self._compute_tool_breakdown(tool_usage) skills = self._compute_skill_breakdown(skill_usage) @@ -172,7 +173,7 @@ class InsightsEngine: "message_count, tool_call_count, input_tokens, output_tokens, " "cache_read_tokens, cache_write_tokens, billing_provider, " "billing_base_url, billing_mode, estimated_cost_usd, " - "actual_cost_usd, cost_status, cost_source") + "actual_cost_usd, cost_status, cost_source, api_call_count") # Pre-computed query strings — f-string evaluated once at class definition, # not at runtime, so no user-controlled value can alter the query structure. @@ -399,7 +400,12 @@ class InsightsEngine: # Computation # ========================================================================= - def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> Dict: + def _compute_overview( + self, + sessions: List[Dict], + message_stats: Dict, + models: Optional[List[Dict]] = None, + ) -> Dict: """Compute high-level overview statistics.""" total_input = sum(s.get("input_tokens") or 0 for s in sessions) total_output = sum(s.get("output_tokens") or 0 for s in sessions) @@ -431,6 +437,9 @@ class InsightsEngine: else: models_without_pricing.add(display) + if models: + total_cost = sum(float(m.get("cost") or 0.0) for m in models) + # Session duration stats (guard against negative durations from clock drift) durations = [] for s in sessions: @@ -473,39 +482,189 @@ class InsightsEngine: "included_cost_sessions": included_cost_sessions, } - def _compute_model_breakdown(self, sessions: List[Dict]) -> List[Dict]: - """Break down usage by model.""" + _GET_MODEL_USAGE_WITH_SOURCE = ( + "SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url," + " u.api_call_count, u.input_tokens, u.output_tokens," + " u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens," + " u.estimated_cost_usd, u.actual_cost_usd, u.cost_status," + " u.cost_source, u.billing_mode" + " FROM session_model_usage u" + " JOIN sessions s ON s.id = u.session_id" + " WHERE s.started_at >= ? AND s.source = ?" + ) + _GET_MODEL_USAGE_ALL = ( + "SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url," + " u.api_call_count, u.input_tokens, u.output_tokens," + " u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens," + " u.estimated_cost_usd, u.actual_cost_usd, u.cost_status," + " u.cost_source, u.billing_mode" + " FROM session_model_usage u" + " JOIN sessions s ON s.id = u.session_id" + " WHERE s.started_at >= ?" + ) + + def _get_model_usage(self, cutoff: float, source: str = None) -> List[Dict]: + """Fetch per-model usage rows within the window (issue #51607). + + Returns an empty list when the table is missing (e.g. a DB opened by + older code that never created it) so the caller can fall back to the + per-session aggregate. + """ + try: + if source: + cursor = self._conn.execute( + self._GET_MODEL_USAGE_WITH_SOURCE, (cutoff, source) + ) + else: + cursor = self._conn.execute(self._GET_MODEL_USAGE_ALL, (cutoff,)) + return [dict(row) for row in cursor.fetchall()] + except sqlite3.OperationalError: + return [] + + def _compute_model_breakdown( + self, sessions: List[Dict], cutoff: float, source: str = None + ) -> List[Dict]: + """Break down token usage and cost by model. + + Tokens and cost are attributed per model from session_model_usage, so a + session that switched models mid-flight (via ``/model``) splits across + every model it used instead of dumping everything on the initial model + (issue #51607). Sessions without per-model rows — e.g. data written + before this table existed and not yet backfilled — fall back to their + single recorded (model, billing_provider) aggregate so nothing is lost. + + Tool calls aren't tied to a specific API invocation, so they stay + attributed to the session's recorded model. + """ model_data = defaultdict(lambda: { - "sessions": 0, "input_tokens": 0, "output_tokens": 0, + "sessions": set(), "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0, - "total_tokens": 0, "tool_calls": 0, "cost": 0.0, + "reasoning_tokens": 0, "total_tokens": 0, "api_calls": 0, + "tool_calls": 0, "cost": 0.0, "actual_cost": 0.0, }) - for s in sessions: - model = s.get("model") or "unknown" + def _accumulate(model, provider, base_url, session_id, inp, out, + cache_read, cache_write, reasoning, *, + stored_cost=None, actual_cost=None, cost_status=None): + model = model or "unknown" # Normalize: strip provider prefix for display display_model = model.split("/")[-1] if "/" in model else model - d = model_data[display_model] - d["sessions"] += 1 - inp = s.get("input_tokens") or 0 - out = s.get("output_tokens") or 0 - cache_read = s.get("cache_read_tokens") or 0 - cache_write = s.get("cache_write_tokens") or 0 + d: Dict[str, Any] = model_data[display_model] + d["sessions"].add(session_id) d["input_tokens"] += inp d["output_tokens"] += out d["cache_read_tokens"] += cache_read d["cache_write_tokens"] += cache_write + d["reasoning_tokens"] += reasoning d["total_tokens"] += inp + out + cache_read + cache_write - d["tool_calls"] += s.get("tool_call_count") or 0 - estimate, status = _estimate_cost(s) + if stored_cost is None: + estimate, status = _estimate_cost( + model, inp, out, + cache_read_tokens=cache_read, cache_write_tokens=cache_write, + provider=provider or None, base_url=base_url, + ) + else: + estimate = float(stored_cost or 0.0) + status = cost_status or "unknown" d["cost"] += estimate - d["has_pricing"] = has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")) + d["actual_cost"] += float(actual_cost or 0.0) d["cost_status"] = status + if has_known_pricing(model, provider or None, base_url): + d["has_pricing"] = True + else: + d.setdefault("has_pricing", False) + return display_model - result = [ - {"model": model, **data} - for model, data in model_data.items() - ] + usage_rows = self._get_model_usage(cutoff, source) + usage_totals = defaultdict(lambda: { + "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, + "cache_write_tokens": 0, "reasoning_tokens": 0, + "api_call_count": 0, "estimated_cost_usd": 0.0, + "actual_cost_usd": 0.0, + }) + for r in usage_rows: + totals: Dict[str, Any] = usage_totals[r["session_id"]] + for key in ( + "input_tokens", "output_tokens", "cache_read_tokens", + "cache_write_tokens", "reasoning_tokens", "api_call_count", + ): + totals[key] += r[key] or 0 + totals["estimated_cost_usd"] += r["estimated_cost_usd"] or 0.0 + totals["actual_cost_usd"] += r["actual_cost_usd"] or 0.0 + d = _accumulate( + r["model"], r["billing_provider"], r.get("billing_base_url"), + r["session_id"], r["input_tokens"] or 0, r["output_tokens"] or 0, + r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0, + r["reasoning_tokens"] or 0, + stored_cost=( + r["estimated_cost_usd"] + if r.get("cost_status") or r.get("cost_source") + else None + ), + actual_cost=r["actual_cost_usd"], + cost_status=r.get("cost_status"), + ) + model_data[d]["api_calls"] += r["api_call_count"] or 0 + + # Reconcile against the aggregate row. This covers legacy sessions, + # interrupted migrations, and absolute cumulative updates without + # double-counting already-attributed route deltas. + for s in sessions: + totals = usage_totals[s["id"]] + inp = max(0, (s.get("input_tokens") or 0) - totals["input_tokens"]) + out = max(0, (s.get("output_tokens") or 0) - totals["output_tokens"]) + cache_read = max( + 0, (s.get("cache_read_tokens") or 0) - totals["cache_read_tokens"] + ) + cache_write = max( + 0, (s.get("cache_write_tokens") or 0) - totals["cache_write_tokens"] + ) + residual_cost = max( + 0.0, float(s.get("estimated_cost_usd") or 0.0) + - totals["estimated_cost_usd"], + ) + residual_actual = max( + 0.0, float(s.get("actual_cost_usd") or 0.0) + - totals["actual_cost_usd"], + ) + residual_calls = max( + 0, (s.get("api_call_count") or 0) - totals["api_call_count"] + ) + if not ( + inp or out or cache_read or cache_write or residual_cost + or residual_actual or residual_calls + ): + continue + d = _accumulate( + s.get("model"), s.get("billing_provider"), + s.get("billing_base_url"), s["id"], + inp, out, cache_read, cache_write, 0, + stored_cost=residual_cost, + actual_cost=residual_actual, + cost_status=s.get("cost_status"), + ) + residual_bucket: Dict[str, Any] = model_data[d] + residual_bucket["api_calls"] += residual_calls + + # Tool calls are attributed by the session's recorded model. + for s in sessions: + tool_calls = s.get("tool_call_count") or 0 + if not tool_calls: + continue + model = s.get("model") or "unknown" + display_model = model.split("/")[-1] if "/" in model else model + model_data[display_model]["tool_calls"] += tool_calls + + result = [] + for model, data in model_data.items(): + entry = {"model": model, **data} + entry["sessions"] = len(data["sessions"]) + # Models that surfaced only via tool-call attribution (no token + # rows) won't have these set by _accumulate — default them so the + # output shape is uniform for downstream/JSON consumers. + entry.setdefault("has_pricing", False) + entry.setdefault("cost_status", "unknown") + result.append(entry) # Sort by tokens first, fall back to session count when tokens are 0 result.sort(key=lambda x: (x["total_tokens"], x["sessions"]), reverse=True) return result diff --git a/agent/kanban_stop.py b/agent/kanban_stop.py new file mode 100644 index 000000000000..e7c2eae828a6 --- /dev/null +++ b/agent/kanban_stop.py @@ -0,0 +1,108 @@ +"""Turn-end guard for kanban workers. + +Kanban workers must end with ``kanban_complete`` or ``kanban_block``. Models +(especially GLM / Qwen families) sometimes narrate the next step +("Let me write the report now") and stop with ``finish_reason=stop`` and no +tool calls. Hermes treats that as a clean exit → ``rc=0`` → dispatcher +``protocol_violation``. + +This module is policy-only: when a kanban worker tries to finish without a +terminal board tool, return a bounded synthetic nudge so the conversation +loop continues instead of exiting. +""" + +from __future__ import annotations + +import os +from typing import Any, Iterable, Optional + + +_TERMINAL_KANBAN_TOOLS = frozenset({"kanban_complete", "kanban_block"}) + +_DEFAULT_MAX_ATTEMPTS = 2 + + +def kanban_stop_nudge_enabled() -> bool: + """Return whether the kanban stop-guard is active for this process. + + On when ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), unless + ``HERMES_KANBAN_STOP_NUDGE`` explicitly disables it. + """ + env = os.environ.get("HERMES_KANBAN_STOP_NUDGE") + if env is not None and env.strip().lower() in {"0", "false", "no", "off"}: + return False + task = (os.environ.get("HERMES_KANBAN_TASK") or "").strip() + return bool(task) + + +def _tool_call_name(tc: Any) -> str: + if isinstance(tc, dict): + fn = tc.get("function") + if isinstance(fn, dict): + return str(fn.get("name") or "") + return str(tc.get("name") or "") + fn = getattr(tc, "function", None) + if fn is not None: + return str(getattr(fn, "name", "") or "") + return str(getattr(tc, "name", "") or "") + + +def session_called_kanban_terminal(messages: Iterable[dict] | None) -> bool: + """True if this conversation already invoked a terminal kanban tool.""" + if not messages: + return False + for msg in messages: + if not isinstance(msg, dict): + continue + role = msg.get("role") + if role == "assistant": + for tc in msg.get("tool_calls") or []: + if _tool_call_name(tc) in _TERMINAL_KANBAN_TOOLS: + return True + elif role == "tool": + name = str(msg.get("name") or "") + if name in _TERMINAL_KANBAN_TOOLS: + return True + return False + + +def build_kanban_stop_nudge( + *, + messages: Iterable[dict] | None = None, + attempts: int = 0, + max_attempts: int = _DEFAULT_MAX_ATTEMPTS, + task_id: Optional[str] = None, +) -> Optional[str]: + """Return a synthetic follow-up when a kanban worker exits without a terminal tool. + + Returns ``None`` when the guard should not fire (not a kanban worker, + already completed/blocked, or nudge budget exhausted). + """ + if not kanban_stop_nudge_enabled(): + return None + if attempts >= max_attempts: + return None + if session_called_kanban_terminal(messages): + return None + + tid = (task_id or os.environ.get("HERMES_KANBAN_TASK") or "").strip() or "this task" + return ( + "[System: You are a Hermes kanban worker. A plain-text reply is NOT a " + "terminal state for the board.\n\n" + f"Task `{tid}` is still `running`. Ending now without a board tool " + "causes a protocol violation (clean exit with no " + "`kanban_complete` / `kanban_block`).\n\n" + "Do this immediately in your next response — do not narrate intent:\n" + "1. Finish any remaining deliverable (write the required file(s) now).\n" + "2. Call `kanban_complete(summary=..., artifacts=[...])` if the work " + "is done, OR `kanban_block(reason=...)` if you are blocked.\n\n" + "Never end a turn with only a promise of future action. Repeated " + "protocol violations will block this task and require manual intervention.]" + ) + + +__all__ = [ + "build_kanban_stop_nudge", + "kanban_stop_nudge_enabled", + "session_called_kanban_terminal", +] diff --git a/agent/memory_manager.py b/agent/memory_manager.py index c8b80a1514e2..32dd8e288841 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -783,6 +783,55 @@ class MemoryManager: exc_info=True, ) + def commit_session_boundary_async( + self, + messages: List[Dict[str, Any]], + *, + new_session_id: str, + parent_session_id: str = "", + reason: str = "new_session", + ) -> None: + """Queue old-session extraction + provider rebinding as ONE serialized task. + + Session rotation (/new) must deliver ``on_session_end`` (end-of-session + extraction — an LLM-bound call that can take seconds) strictly BEFORE + ``on_session_switch`` (which rebinds provider-internal ``_session_id`` / + turn buffers to the new session). Running extraction inline blocked the + /new command for the whole LLM round-trip (#16454); running it on an + ad-hoc thread raced the inline switch — providers key off internal + state, so a late ``on_session_end`` ran against post-switch bindings + (transcript misattributed to the new session id, double-ingest of the + old turn buffer, new-session buffers cleared). + + Submitting BOTH hooks as one task on the manager's single background + worker gives both properties at a single chokepoint: the caller returns + immediately, and the worker's FIFO order serializes end→switch against + every other provider write (per-turn ``sync_all``, prefetches), which + already share the same worker. If the executor is unavailable, + ``_submit_background`` degrades to inline execution — the pre-#16454 + synchronous behavior, slow but correct. + """ + if not self._providers: + return + snapshot = list(messages or []) + + def _run() -> None: + try: + self.on_session_end(snapshot) + except Exception as e: # pragma: no cover - on_session_end guards per-provider + logger.warning("Session-boundary extraction failed: %s", e) + try: + self.on_session_switch( + new_session_id, + parent_session_id=parent_session_id, + reset=True, + reason=reason, + ) + except Exception as e: # pragma: no cover - on_session_switch guards per-provider + logger.warning("Session-boundary switch failed: %s", e) + + self._submit_background(_run) + def on_session_switch( self, new_session_id: str, diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 9700f4abe85d..43691fd7f997 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -14,6 +14,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import Any from agent.auxiliary_client import call_llm +from agent.message_content import flatten_message_text from agent.transports import get_transport logger = logging.getLogger(__name__) @@ -119,11 +120,54 @@ _REFERENCE_SYSTEM_PROMPT = ( -def _slot_label(slot: dict[str, str]) -> str: - return f"{slot.get('provider', '').strip()}:{slot.get('model', '').strip()}" +def _slot_label(slot: dict[str, Any]) -> str: + label = f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}" + effort = str(slot.get("reasoning_effort") or "").strip() + return f"{label}[reasoning={effort}]" if effort else label -def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]: +def _slot_reasoning_config(slot: dict[str, Any]) -> dict[str, Any] | None: + """Translate optional per-MoA-slot reasoning_effort into runtime config.""" + effort = slot.get("reasoning_effort") + try: + from hermes_constants import parse_reasoning_effort + + return parse_reasoning_effort(effort) + except Exception: # pragma: no cover - defensive; bad config must not break MoA + return None + + +def _aggregator_reasoning_config(aggregator: dict[str, Any]) -> dict[str, Any] | None: + """Resolve the aggregator's reasoning config: slot > per-model > global. + + The aggregator is MoA's ACTING model, so when its slot doesn't pin a + reasoning_effort it must resolve exactly like any other acting model: + through the shared chokepoint (``resolve_reasoning_config``), which + applies ``agent.reasoning_overrides`` for the slot's model first, then + the global ``agent.reasoning_effort``. Without this the main loop's + reasoning gates (keyed to the virtual ``moa://local`` identity) never + fire, so the aggregator silently ran at the backend default (#64187). + + Reference advisors intentionally do NOT get this fallback: they are side + calls (like auxiliary tasks), and inheriting a global ``xhigh`` into every + advisor fan-out would silently multiply cost. Their depth is slot-or- + provider-default only. + """ + cfg = _slot_reasoning_config(aggregator) + if cfg is not None: + return cfg + try: + from hermes_cli.config import load_config + from hermes_constants import resolve_reasoning_config + + return resolve_reasoning_config( + load_config() or {}, str(aggregator.get("model") or "") + ) + except Exception: # pragma: no cover - defensive; bad config must not break MoA + return None + + +def _slot_runtime(slot: dict[str, Any]) -> dict[str, Any]: """Resolve a reference/aggregator slot to real runtime call kwargs. A MoA slot is just a model selection — it must be called the same way any @@ -275,6 +319,7 @@ def _run_reference( messages=messages, temperature=temperature, max_tokens=max_tokens, + reasoning_config=_slot_reasoning_config(slot), **runtime, ) usage = CanonicalUsage() @@ -470,13 +515,52 @@ def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: for msg in messages: role = msg.get("role") content = msg.get("content") - text = content if isinstance(content, str) else "" + # Flatten structured content (lists of parts) to visible text. Content + # arrives as a list — not a string — in two common cases: + # 1. Anthropic prompt-cache decoration: conversation_loop runs + # apply_anthropic_cache_control BEFORE the MoA facade, converting + # string content to [{"type": "text", "text": ..., "cache_control": + # ...}]. A str-only read here flattened the user's ENTIRE prompt to + # "" — Claude references then 400'd ("messages: at least one + # message is required") while tolerant models answered "no user + # request is present". + # 2. Multimodal turns (pasted image → text + image_url parts) and + # multimodal tool results (screenshots). + # flatten_message_text extracts the text parts and skips image parts, + # and returns strings unchanged — so a decorated and an undecorated + # transcript produce a byte-identical advisory view (which keeps the + # advisory prefix stable across iterations for advisor prompt caching). + text = flatten_message_text(content) if role == "system": continue if role == "user": - if text.strip(): - last_user_content = text + if not text.strip() and isinstance(content, list) and content: + # Structured content with no extractable text (e.g. an + # image-only turn). Emitting an empty user message would be + # dropped/rejected by strict providers (Anthropic 400s on + # empty text blocks — the original "closed" preset failure + # mode), and silently skipping the turn would break + # user/assistant alternation in the advisory view. Substitute + # a placeholder so the reference knows a non-text turn + # happened. Only structured content qualifies — an empty or + # whitespace-only STRING turn carries nothing and is dropped + # below instead. + text = "[user sent non-text content (e.g. an image attachment)]" + if not text.strip(): + # Genuinely empty user turn (content="" / None). It carries + # nothing advisory, and strict providers (Kimi/Moonshot, ZAI, + # and others that enforce non-empty user content) reject it + # with 400 "message ... with role 'user' must not be empty" — + # the same way the assistant branch below drops turns with no + # parts. Lenient providers (DeepSeek) accept the empty turn, + # which is why a MoA fan-out would fail on one reference and + # pass on another for the identical rendered view. The + # advisory view is already not strictly alternating (adjacent + # assistant turns occur in every tool loop), so dropping a + # contentless turn is safe. + continue + last_user_content = text rendered.append({"role": "user", "content": text}) elif role == "assistant": parts: list[str] = [] @@ -517,8 +601,10 @@ def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: if last_user_content is not None: return [{"role": "user", "content": last_user_content}] for msg in reversed(messages): - if msg.get("role") == "user" and isinstance(msg.get("content"), str): - return [{"role": "user", "content": msg["content"]}] + if msg.get("role") == "user": + fallback_text = flatten_message_text(msg.get("content")) + if fallback_text.strip(): + return [{"role": "user", "content": fallback_text}] return rendered @@ -638,6 +724,7 @@ def aggregate_moa_context( messages=agg_messages, temperature=aggregator_temperature, max_tokens=max_tokens, + reasoning_config=_aggregator_reasoning_config(aggregator), **agg_runtime, ) synthesis = _extract_text(response) @@ -671,13 +758,28 @@ def _attach_reference_guidance(agg_messages: list[dict[str, Any]], guidance: str Appending at the very end keeps the ``[system][task][tool-history]`` prefix stable and cache-reusable (only the new block re-prefills), and gives the aggregator the references with recency. Merge into the last message only when - it is already a trailing string ``user`` turn (plain chat — still at the end). + it is already a trailing ``user`` turn (plain chat — still at the end). + + A trailing user turn's content may be a STRING or a LIST of content parts — + Anthropic prompt-cache decoration (which runs before the MoA facade) + converts string content to ``[{"type": "text", ..., "cache_control": ...}]``, + and multimodal turns are lists natively. Both shapes are merged in place: + appending a new text part AFTER the cache_control-marked part keeps the + cached prefix byte-stable (the marker still terminates it) while the + turn-varying guidance rides outside the cached span. Appending a SEPARATE + user message here instead would produce two consecutive user turns — + strict providers reject that. """ last = agg_messages[-1] if agg_messages else None - if last is not None and last.get("role") == "user" and isinstance(last.get("content"), str): - last["content"] = last["content"] + "\n\n" + guidance - else: - agg_messages.append({"role": "user", "content": guidance}) + if last is not None and last.get("role") == "user": + last_content = last.get("content") + if isinstance(last_content, str): + last["content"] = last_content + "\n\n" + guidance + return + if isinstance(last_content, list): + last["content"] = [*last_content, {"type": "text", "text": "\n\n" + guidance}] + return + agg_messages.append({"role": "user", "content": guidance}) class MoAChatCompletions: @@ -1017,6 +1119,7 @@ class MoAChatCompletions: max_tokens=agg_kwargs.get("max_tokens"), tools=agg_kwargs.get("tools"), extra_body=agg_kwargs.get("extra_body"), + reasoning_config=_aggregator_reasoning_config(aggregator), **stream_kwargs, **_slot_runtime(aggregator), ) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index c9a7d5771a85..03c05177b1f6 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -47,7 +47,7 @@ def _resolve_requests_verify() -> bool | str: # are preserved so the full model name reaches cache lookups and server queries. _PROVIDER_PREFIXES: frozenset[str] = frozenset({ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", - "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", + "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", "deepinfra", "opencode-zen", "opencode-go", "kilocode", "alibaba", "novita", "qwen-oauth", "xiaomi", @@ -58,7 +58,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({ # Common aliases "google", "google-gemini", "google-ai-studio", "glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot", - "github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", + "github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", "deep-infra", "ollama", "stepfun", "opencode", "zen", "go", "kilo", "dashscope", "aliyun", "qwen", "mimo", "xiaomi-mimo", @@ -111,6 +111,15 @@ _MODEL_CACHE_TTL = 3600 _endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {} _endpoint_model_metadata_cache_time: Dict[str, float] = {} _ENDPOINT_MODEL_CACHE_TTL = 300 +# Bounded-lifetime cache: after the first successful probe we remember the +# server type so subsequent refreshes skip the full waterfall (no more 404 +# spam every 5 minutes on non-matching endpoints like /api/v1/models on vllm). +# Entries expire after _ENDPOINT_PROBE_TTL_SECONDS so a server swap on the +# same port (stop Ollama, start LM Studio) is eventually re-detected instead +# of being pinned to the stale type for the whole process lifetime. +# Values are (server_type, monotonic_timestamp). +_ENDPOINT_PROBE_TTL_SECONDS = 3600.0 +_endpoint_probe_path_cache: Dict[str, tuple] = {} def _get_model_metadata_cache_path() -> Path: @@ -220,6 +229,12 @@ DEFAULT_CONTEXT_LENGTHS = { # ChatGPT Codex OAuth caps it at 272K; both paths resolve via their own # provider-aware branches (_resolve_codex_oauth_context_length + models.dev). # This hardcoded value is only reached when every probe misses. + # GPT-5.6 series (Sol/Terra/Luna, GA 2026-07-09) — 1.05M on the direct + # OpenAI API (same as gpt-5.5). Codex OAuth caps these at 272K. + # (Lookups length-sort keys at match time, so dict order is cosmetic.) + "gpt-5.6-luna": 1050000, + "gpt-5.6-terra": 1050000, + "gpt-5.6-sol": 1050000, "gpt-5.5": 1050000, "gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4) "gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4) @@ -289,11 +304,13 @@ DEFAULT_CONTEXT_LENGTHS = { # Premium+); /v1/responses additionally enforces a ~262144 input+output # budget, but the usable context (what we track here) is 200k. "grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI) + "grok-build-latest": 500000, # alias of grok-4.5 (early access) "grok-build": 256000, # grok-build-0.1 "grok-code-fast": 256000, # grok-code-fast-1 "grok-2-vision": 8192, # grok-2-vision, -1212, -latest "grok-4-fast": 2000000, # grok-4-fast-(non-)reasoning, also matches -reasoning "grok-4.20": 2000000, # grok-4.20-0309-(non-)reasoning, -multi-agent-0309 + "grok-4.5": 500000, # grok-4.5, grok-4.5-latest — 500K context per docs.x.ai "grok-4.3": 1000000, # grok-4.3, grok-4.3-latest — 1M context per docs.x.ai "grok-4": 256000, # grok-4, grok-4-0709 "grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast @@ -301,6 +318,16 @@ DEFAULT_CONTEXT_LENGTHS = { "grok": 131072, # catch-all (grok-beta, unknown grok-*) # Kimi "kimi": 262144, + # Upstage Solar — api.upstage.ai/v1/models does not return context_length, + # so these fallbacks keep token budgeting / compression from probing down + # to the 128k default. Ids are matched longest-first, so dated variants + # (e.g. solar-pro3-250127) resolve via their family prefix. + # Sources: Solar Pro 3 = 128K, Solar Pro 2 = 64K, Solar Mini = 32K, + # Solar Open 2 = 256K. + "solar-open2": 262144, # 256K + "solar-pro3": 131072, + "solar-pro2": 65536, + "solar-mini": 32768, # Tencent — Hy3 Preview (Hunyuan) with 256K context window. # OpenRouter live metadata reports 262144 (256 × 1024); align the # static fallback so cache and offline both agree (issue #22268). @@ -347,6 +374,11 @@ _GROK_EFFORT_CAPABLE_PREFIXES = ( "grok-3-mini", "grok-4.20-multi-agent", "grok-4.3", + # grok-4.5: verified live against /v1/responses 2026-07-08 — accepts + # effort low/medium/high (default: high when omitted) but REJECTS + # "none" ("This model does not support `reasoning_effort` value `none`"), + # unlike grok-4.3. models.dev agrees: effort values [low, medium, high]. + "grok-4.5", ) @@ -625,66 +657,109 @@ def is_local_endpoint(base_url: str) -> bool: return False +def _localhost_to_ipv4(url: str) -> str: + """Rewrite a ``localhost`` HOST to ``127.0.0.1`` in a probe URL. + + On Windows dual-stack machines, httpx resolves ``localhost`` to ``::1`` + first and pays a ~2s IPv6 connect timeout before falling back to IPv4 + when the local server only listens on IPv4 (LM Studio, Ollama defaults). + Probing the IPv4 loopback directly skips that penalty. + + Only the URL's own host component is rewritten (anchored at the scheme), + so a non-localhost URL whose path or query merely embeds the substring + ``http://localhost...`` (e.g. ``?upstream=http://localhost:11434``) + passes through untouched. + """ + if not url: + return url + return re.sub( + r"^(https?://)localhost(?=[:/]|$)", + r"\g<1>127.0.0.1", + url, + count=1, + ) + + def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: """Detect which local server is running at base_url by probing known endpoints. Returns one of: "ollama", "lm-studio", "vllm", "llamacpp", or None. + + The result is cached for the lifetime of the process so that repeated + calls (e.g. every 5-minute metadata refresh) never re-run the waterfall + and never spray 404s at endpoints the server does not expose. """ import httpx normalized = _normalize_base_url(base_url) + + # Resolve localhost to IPv4 to avoid 2s IPv6 timeout on Windows dual-stack. + # Applied to ``normalized`` before deriving server/LM Studio URLs AND + # before the cache lookup, so localhost and 127.0.0.1 share a cache entry. + normalized = _localhost_to_ipv4(normalized) + server_url = normalized if server_url.endswith("/v1"): server_url = server_url[:-3] - lmstudio_url = _lmstudio_server_root(base_url) + lmstudio_url = _lmstudio_server_root(normalized) + + cached = _endpoint_probe_path_cache.get(server_url) + if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS: + return cached[0] headers = _auth_headers(api_key) + result: Optional[str] = None try: with httpx.Client(timeout=2.0, headers=headers) as client: # LM Studio exposes /api/v1/models — check first (most specific) try: r = client.get(f"{lmstudio_url}/api/v1/models") if r.status_code == 200: - return "lm-studio" + result = "lm-studio" except Exception: pass - # Ollama exposes /api/tags and responds with {"models": [...]} - # LM Studio returns {"error": "Unexpected endpoint"} with status 200 - # on this path, so we must verify the response contains "models". - try: - r = client.get(f"{server_url}/api/tags") - if r.status_code == 200: - try: + if result is None: + # Ollama exposes /api/tags and responds with {"models": [...]} + # LM Studio returns {"error": "Unexpected endpoint"} with status 200 + # on this path, so we must verify the response contains "models". + try: + r = client.get(f"{server_url}/api/tags") + if r.status_code == 200: + try: + data = r.json() + if "models" in data: + result = "ollama" + except Exception: + pass + except Exception: + pass + if result is None: + # llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix) + try: + r = client.get(f"{server_url}/v1/props") + if r.status_code != 200: + r = client.get(f"{server_url}/props") # fallback for older builds + if r.status_code == 200 and "default_generation_settings" in r.text: + result = "llamacpp" + except Exception: + pass + if result is None: + # vLLM: /version + try: + r = client.get(f"{server_url}/version") + if r.status_code == 200: data = r.json() - if "models" in data: - return "ollama" - except Exception: - pass - except Exception: - pass - # llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix) - try: - r = client.get(f"{server_url}/v1/props") - if r.status_code != 200: - r = client.get(f"{server_url}/props") # fallback for older builds - if r.status_code == 200 and "default_generation_settings" in r.text: - return "llamacpp" - except Exception: - pass - # vLLM: /version - try: - r = client.get(f"{server_url}/version") - if r.status_code == 200: - data = r.json() - if "version" in data: - return "vllm" - except Exception: - pass + if "version" in data: + result = "vllm" + except Exception: + pass except Exception: pass - return None + if result is not None: + _endpoint_probe_path_cache[server_url] = (result, time.monotonic()) + return result def _iter_nested_dicts(value: Any): @@ -742,6 +817,24 @@ def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]: pricing["completion"] = str(float(novita_output) / 10_000 / 1_000_000) return pricing + # DeepInfra ships pricing under ``metadata.pricing`` with $/MTok values: + # ``input_tokens``, ``output_tokens``, ``cache_read_tokens``. Convert to + # per-token strings so the generic cost machinery (usage_pricing.py) + # consumes them through the same path as OpenRouter / OpenAI. + metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else None + deepinfra_pricing = metadata.get("pricing") if metadata else None + if isinstance(deepinfra_pricing, dict) and any( + k in deepinfra_pricing for k in ("input_tokens", "output_tokens", "cache_read_tokens") + ): + result: Dict[str, Any] = {} + if deepinfra_pricing.get("input_tokens") is not None: + result["prompt"] = str(float(deepinfra_pricing["input_tokens"]) / 1_000_000) + if deepinfra_pricing.get("output_tokens") is not None: + result["completion"] = str(float(deepinfra_pricing["output_tokens"]) / 1_000_000) + if deepinfra_pricing.get("cache_read_tokens") is not None: + result["cache_read"] = str(float(deepinfra_pricing["cache_read_tokens"]) / 1_000_000) + return result + alias_map = { "prompt": ("prompt", "input", "input_cost_per_token", "prompt_token_cost"), "completion": ("completion", "output", "output_cost_per_token", "completion_token_cost"), @@ -788,7 +881,10 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any return _model_metadata_cache try: - response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify()) + # Tuple (connect, read) — flat timeout=10 means urllib3 can block 10s per + # retry stage through proxies that 403 CONNECT, ballooning to minutes + # (#46620). 5s connect / 10s read fails fast on unreachable hosts. + response = requests.get(OPENROUTER_MODELS_URL, timeout=(5, 10), verify=_resolve_requests_verify()) response.raise_for_status() data = response.json() @@ -866,7 +962,7 @@ def fetch_endpoint_model_metadata( response = requests.get( server_url.rstrip("/") + "/api/v1/models", headers=headers, - timeout=10, + timeout=(5, 10), verify=_resolve_requests_verify(), ) response.raise_for_status() @@ -914,7 +1010,7 @@ def fetch_endpoint_model_metadata( for candidate in candidates: url = candidate.rstrip("/") + "/models" try: - response = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify()) + response = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify()) response.raise_for_status() payload = response.json() cache: Dict[str, Dict[str, Any]] = {} @@ -1009,19 +1105,29 @@ def _load_context_cache() -> Dict[str, int]: try: with open(path, encoding="utf-8") as f: data = yaml.safe_load(f) or {} - return data.get("context_lengths", {}) + return data.get("context_lengths") or {} except Exception as e: logger.debug("Failed to load context length cache: %s", e) return {} +def _context_cache_key(model: str, base_url: str) -> str: + """Canonical ``model@base_url`` key for the persistent context cache. + + Trailing slashes are stripped so ``http://host/v1`` and + ``http://host/v1/`` share one entry instead of creating duplicates + that can go stale independently. + """ + return f"{model}@{(base_url or '').rstrip('/')}" + + def save_context_length(model: str, base_url: str, length: int) -> None: """Persist a discovered context length for a model+provider combo. Cache key is ``model@base_url`` so the same model name served from different providers can have different limits. """ - key = f"{model}@{base_url}" + key = _context_cache_key(model, base_url) cache = _load_context_cache() if cache.get(key) == length: return # already stored @@ -1038,18 +1144,43 @@ def save_context_length(model: str, base_url: str, length: int) -> None: def get_cached_context_length(model: str, base_url: str) -> Optional[int]: """Look up a previously discovered context length for model+provider.""" - key = f"{model}@{base_url}" + key = _context_cache_key(model, base_url) cache = _load_context_cache() - return cache.get(key) + hit = cache.get(key) + if hit is not None: + return hit + # Legacy rows written before key normalization may carry a trailing + # slash — honor them rather than re-probing. Checked regardless of the + # caller's slash form: the row's shape and the caller's shape can differ + # in either direction (old slashed row + new normalized config, or the + # reverse), so probe the literal form and the slashed canonical form. + for legacy_key in (f"{model}@{base_url}", f"{key}/"): + if legacy_key != key: + hit = cache.get(legacy_key) + if hit is not None: + return hit + return None def _invalidate_cached_context_length(model: str, base_url: str) -> None: """Drop a stale cache entry so it gets re-resolved on the next lookup.""" - key = f"{model}@{base_url}" + key = _context_cache_key(model, base_url) cache = _load_context_cache() - if key not in cache: + # Invalidation must also drop the in-memory TTL probe entries for this + # pair — otherwise the next resolution inside the TTL window reuses the + # very value we just declared stale and re-persists it. + bare = _strip_provider_prefix(model) + stripped = (base_url or "").rstrip("/") + _LOCAL_CTX_PROBE_CACHE.pop((bare, stripped), None) + _LOCAL_CTX_PROBE_CACHE.pop(("ollama_show", bare, stripped), None) + # Clear every key shape for this pair: canonical, the caller's literal + # form, and the slashed legacy form — same set get_cached_context_length + # consults, so a lookup can never resurrect a row invalidation missed. + stale_keys = {key, f"{model}@{base_url}", f"{key}/"} + if not any(k in cache for k in stale_keys): return - del cache[key] + for k in stale_keys: + cache.pop(k, None) path = _get_context_cache_path() try: path.parent.mkdir(parents=True, exist_ok=True) @@ -1336,7 +1467,7 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option import httpx bare_model = _strip_provider_prefix(model) - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1397,7 +1528,7 @@ def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") - except Exception: return None - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1436,6 +1567,12 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti hosting behind a reverse proxy, etc. For non-Ollama servers the POST returns 404/405 quickly; the function handles errors gracefully. + Results are cached in ``_LOCAL_CTX_PROBE_CACHE`` (same 30s TTL, + positive-only — see ``_query_local_context_length``) so back-to-back + resolutions during one startup issue a single POST instead of one per + call site. Failures are never memoized: a server that isn't up yet must + be re-probed once it comes up. + For hosted servers the GGUF ``model_info.*.context_length`` is the authoritative source: the user can't set their own ``num_ctx``, and the OpenAI-compat ``/v1/models`` endpoint correctly omits ``context_length`` @@ -1447,9 +1584,28 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti The order is flipped vs ``query_ollama_num_ctx()`` because local users control ``num_ctx`` themselves; hosted users can't. """ + import time as _time + + # Namespaced cache key: shares the TTL store with + # _query_local_context_length but never collides with its (model, url) + # keys — the two probes can return different values for the same pair. + cache_key = ("ollama_show", _strip_provider_prefix(model), base_url.rstrip("/")) + now = _time.monotonic() + cached = _LOCAL_CTX_PROBE_CACHE.get(cache_key) + if cached is not None and (now - cached[1]) < _LOCAL_CTX_PROBE_TTL_SECONDS: + return cached[0] + + result = _query_ollama_api_show_uncached(model, base_url, api_key=api_key) + if result: # positive-only — never memoize a failed probe + _LOCAL_CTX_PROBE_CACHE[cache_key] = (result, now) + return result + + +def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "") -> Optional[int]: + """Uncached body of ``_query_ollama_api_show`` — one POST to ``/api/show``.""" import httpx - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1568,10 +1724,10 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str model = _strip_provider_prefix(model) # Strip /v1 suffix to get the server root - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] - lmstudio_url = _lmstudio_server_root(base_url) + lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url)) headers = _auth_headers(api_key) @@ -1681,7 +1837,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> "x-api-key": api_key, "anthropic-version": "2023-06-01", } - resp = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify()) + resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify()) if resp.status_code != 200: return None data = resp.json() @@ -1715,6 +1871,9 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = { "gpt-5.3-codex-spark": 128_000, "gpt-5.2-codex": 272_000, "gpt-5.4-mini": 272_000, + "gpt-5.6-sol": 272_000, + "gpt-5.6-terra": 272_000, + "gpt-5.6-luna": 272_000, "gpt-5.5": 272_000, "gpt-5.4": 272_000, "gpt-5.2": 272_000, @@ -1748,7 +1907,7 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]: resp = requests.get( "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0", headers={"Authorization": f"Bearer {access_token}"}, - timeout=10, + timeout=(5, 10), verify=_resolve_requests_verify(), ) if resp.status_code != 200: @@ -2432,5 +2591,82 @@ def estimate_request_tokens_rough( if messages: total += estimate_messages_tokens_rough(messages) if tools: - total += (len(str(tools)) + 3) // 4 + total += _estimate_tools_tokens_rough(tools) return total + + +# NOTE: tool schemas can be large. Avoid repeated `str(tools)` conversions, +# which are CPU-heavy and can stall GUI event loops under GIL pressure. +# +# Keyed by ``id(tools)``. A long-lived gateway/desktop backend builds many +# transient tool lists over its lifetime, so the cache is bounded and evicts +# oldest-first (insertion-ordered dict) once it exceeds the cap. The cap is +# generous relative to how rarely toolsets are rebuilt within a process. +_TOOLS_TOKENS_CACHE: dict[int, Tuple[int, str, str, int]] = {} +_TOOLS_TOKENS_CACHE_MAX = 256 + + +def _tool_name_for_cache(tool: Any) -> str: + if not isinstance(tool, dict): + return "" + fn = tool.get("function") + if isinstance(fn, dict): + name = fn.get("name") + if isinstance(name, str): + return name + name = tool.get("name") + return name if isinstance(name, str) else "" + + +def _estimate_tools_tokens_rough(tools: List[Dict[str, Any]]) -> int: + if not tools: + return 0 + + # Cache by list identity. Tools are rebuilt rarely (toolset changes), + # but token estimates are requested frequently (preflight, compaction). + key = id(tools) + n = len(tools) + first = _tool_name_for_cache(tools[0]) if n else "" + last = _tool_name_for_cache(tools[-1]) if n else "" + + cached = _TOOLS_TOKENS_CACHE.get(key) + if cached is not None: + cached_n, cached_first, cached_last, cached_tokens = cached + if cached_n == n and cached_first == first and cached_last == last: + return cached_tokens + + # Fast, stable rough estimate: sum lengths of the major schema fields. + # This avoids the pathological `str(tools)` path while still scaling with + # schema size (descriptions + parameters dominate). + total_chars = 0 + for tool in tools: + if not isinstance(tool, dict): + continue + fn = tool.get("function") + if isinstance(fn, dict): + name = fn.get("name") or "" + desc = fn.get("description") or "" + params = fn.get("parameters") or {} + else: + name = tool.get("name") or "" + desc = tool.get("description") or "" + params = tool.get("parameters") or {} + + if isinstance(name, str): + total_chars += len(name) + if isinstance(desc, str): + total_chars += len(desc) + # Parameters can be nested; JSON is closer to over-the-wire size than repr(). + try: + total_chars += len(json.dumps(params, ensure_ascii=False, separators=(",", ":"))) + except Exception: + total_chars += len(str(params)) + + tokens = (total_chars + 3) // 4 + # Bound the cache: drop the oldest entry when the cap is exceeded so a + # long-running process can't accumulate an unbounded number of stale + # ``id(tools)`` entries (id values are recycled after GC anyway). + if len(_TOOLS_TOKENS_CACHE) >= _TOOLS_TOKENS_CACHE_MAX: + _TOOLS_TOKENS_CACHE.pop(next(iter(_TOOLS_TOKENS_CACHE)), None) + _TOOLS_TOKENS_CACHE[key] = (n, first, last, tokens) + return tokens diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index abb70b543cbb..957b3f5fa1ce 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -7,6 +7,7 @@ assemble pieces, then combines them with memory and ephemeral prompts. import json import logging import os +import sys import threading import contextvars from collections import OrderedDict @@ -17,6 +18,8 @@ from typing import Optional from agent.runtime_cwd import resolve_agent_cwd from agent.skill_utils import ( + EXCLUDED_SKILL_DIRS, + SKILL_SUPPORT_DIRS, extract_skill_conditions, extract_skill_description, get_all_skills_dirs, @@ -25,6 +28,7 @@ from agent.skill_utils import ( parse_frontmatter, skill_matches_environment, skill_matches_platform, + skill_matches_platform_list, ) from utils import atomic_json_write @@ -655,19 +659,7 @@ PLATFORM_HINTS = { "Standard Markdown is automatically converted to Telegram formatting. " "Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, " "`inline code`, ```code blocks```, [links](url), and ## headers. " - "Telegram now supports rich Markdown, so lean into it: whenever it " - "makes the answer clearer or easier to scan, actively reach for real " - "Markdown tables (pipe `| col | col |` syntax), bullet and numbered " - "lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, " - "collapsible details, footnotes/references, math/formulas (`$...$`, " - "`$$...$$`), underline, subscript/superscript, marked (highlighted) " - "text, and anchors. Default to structured formatting over dense " - "paragraphs for any comparison, set of steps, key/value summary, or " - "tabular data. Prefer real Markdown tables and task lists over " - "hand-built bullet substitutes when presenting structured data; these " - "degrade gracefully (tables become readable bullet groups) when rich " - "rendering is unavailable, but advanced constructs like math and " - "collapsible details may render as plain source text in that case. " + "Prefer bullet lists and labeled key:value pairs for structured data. " "You can send media files natively: to deliver a file to the user, " "include MEDIA:/absolute/path/to/file in your response. Images " "(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice " @@ -861,6 +853,27 @@ PLATFORM_HINTS = { ), } +# Telegram rich-messages extension — only injected when the user has opted in +# to ``platforms.telegram.extra.rich_messages: true``. The base +# PLATFORM_HINTS["telegram"] covers MarkdownV2-compatible constructs; this +# extension adds the Bot API 10.1 rich-Markdown guidance (tables, task lists, +# collapsible details, math, etc.). +TELEGRAM_RICH_MESSAGES_HINT = ( + "Telegram now supports rich Markdown, so lean into it: whenever it " + "makes the answer clearer or easier to scan, actively reach for real " + "Markdown tables (pipe `| col | col |` syntax), bullet and numbered " + "lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, " + "collapsible details, footnotes/references, math/formulas (`$...$`, " + "`$$...$$`), underline, subscript/superscript, marked (highlighted) " + "text, and anchors. Default to structured formatting over dense " + "paragraphs for any comparison, set of steps, key/value summary, or " + "tabular data. Prefer real Markdown tables and task lists over " + "hand-built bullet substitutes when presenting structured data; these " + "degrade gracefully (tables become readable bullet groups) when rich " + "rendering is unavailable, but advanced constructs like math and " + "collapsible details may render as plain source text in that case. " +) + # --------------------------------------------------------------------------- # Environment hints — execution-environment awareness for the agent. # Unlike PLATFORM_HINTS (which describe the messaging channel), these describe @@ -1271,13 +1284,26 @@ def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None: def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]: """Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files.""" manifest: dict[str, list[int]] = {} - for filename in ("SKILL.md", "DESCRIPTION.md"): - for path in iter_skill_index_files(skills_dir, filename): + skills_dir_str = str(skills_dir) + base = os.path.join(skills_dir_str, "") + prefix_len = len(base) + for root, dirs, files in os.walk(skills_dir_str, followlinks=True): + has_skill_md = "SKILL.md" in files + dirs[:] = [ + d + for d in dirs + if d not in EXCLUDED_SKILL_DIRS + and not (has_skill_md and d in SKILL_SUPPORT_DIRS) + ] + for filename in ("SKILL.md", "DESCRIPTION.md"): + if filename not in files: + continue + path = os.path.join(root, filename) try: - st = path.stat() + st = os.stat(path) except OSError: continue - manifest[str(path.relative_to(skills_dir))] = [st.st_mtime_ns, st.st_size] + manifest[path[prefix_len:]] = [st.st_mtime_ns, st.st_size] return manifest @@ -1409,6 +1435,22 @@ def _skill_should_show( return True +def _current_session_platform_hint() -> str: + """Return the active platform without importing the gateway package on CLI startup.""" + platform = os.environ.get("HERMES_PLATFORM") or os.environ.get("HERMES_SESSION_PLATFORM") + if platform: + return platform + + session_context = sys.modules.get("gateway.session_context") + get_session_env = getattr(session_context, "get_session_env", None) if session_context else None + if get_session_env is None: + return "" + try: + return get_session_env("HERMES_SESSION_PLATFORM") or "" + except Exception: + return "" + + def build_skills_system_prompt( available_tools: "set[str] | None" = None, available_toolsets: "set[str] | None" = None, @@ -1443,15 +1485,10 @@ def build_skills_system_prompt( # ── Layer 1: in-process LRU cache ───────────────────────────────── # Include the resolved platform so per-platform disabled-skill lists # produce distinct cache entries (gateway serves multiple platforms). - from gateway.session_context import get_session_env - _platform_hint = ( - os.environ.get("HERMES_PLATFORM") - or get_session_env("HERMES_SESSION_PLATFORM") - or "" - ) + _platform_hint = _current_session_platform_hint() disabled = get_disabled_skill_names(_platform_hint or None) cache_key = ( - str(skills_dir.resolve()), + str(skills_dir), tuple(str(d) for d in external_dirs), tuple(sorted(str(t) for t in (available_tools or set()))), tuple(sorted(str(ts) for ts in (available_toolsets or set()))), @@ -1480,7 +1517,7 @@ def build_skills_system_prompt( category = entry.get("category") or "general" frontmatter_name = entry.get("frontmatter_name") or skill_name platforms = entry.get("platforms") or [] - if not skill_matches_platform({"platforms": platforms}): + if not skill_matches_platform_list(platforms): continue if frontmatter_name in disabled or skill_name in disabled: continue diff --git a/agent/reactions.py b/agent/reactions.py new file mode 100644 index 000000000000..375366ff7099 --- /dev/null +++ b/agent/reactions.py @@ -0,0 +1,56 @@ +"""Token-free detection of user *reactions* to the agent. + +Currently the only reaction is ``vibe`` — an expression of affection or +gratitude toward the agent (``ily``, ``<3``, ``love you``, ``good bot``, a heart +emoji, …). Detection is a curated regex/lexicon: **no model call, no tokens**. + +This is the single source of truth shared by every surface — the CLI pet, the +TUI heart, and the desktop floating hearts all react off the same signal, +delivered via ``AIAgent.reaction_callback`` (wired per interactive host). + +Generalized on purpose: :func:`detect_reaction` returns a reaction *kind* +string, so new kinds (other emoji reactions, etc.) can be added here without +touching any caller. We match affection specifically — not general positive +sentiment — so "this is great" does NOT fire, but "good bot" / "❤️" do. +""" + +from __future__ import annotations + +import re + +#: The affection/gratitude reaction — the only kind today. +VIBE = "vibe" + +# Curated affection lexicon. Kept deliberately narrow: gratitude + love aimed at +# the agent, heart emoji, and ``<3`` (but not the broken heart `` str | None: + """Return the reaction kind for *text* (currently :data:`VIBE`), or ``None``. + + Pure, token-free, and safe to call on every user turn. + """ + if not text: + return None + + return VIBE if _VIBE_RE.search(text) else None diff --git a/agent/reasoning_timeouts.py b/agent/reasoning_timeouts.py index 9e0b5cab9b91..768ce0494def 100644 --- a/agent/reasoning_timeouts.py +++ b/agent/reasoning_timeouts.py @@ -66,9 +66,13 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = ( ("nemotron-3-ultra", 600), ("nemotron-3-super", 600), ("nemotron-3-nano", 300), - # DeepSeek — R1 reasoning model on hosted NIM / DeepSeek direct. + # DeepSeek — R1 and V4 reasoning models on hosted NIM / DeepSeek direct. + # V4 series emits reasoning_content in a separate delta field before + # final content, requiring the same extended stale timeout floor. ("deepseek-r1", 600), ("deepseek-reasoner", 600), + ("deepseek-v4-flash", 600), + ("deepseek-v4-pro", 600), # Qwen — QwQ reasoning + Qwen3 thinking variants. QwQ-32B # preview is the stable slug; ``qwen3`` covers the family of # thinking-mode Qwen3 models (qwen3-235b-a22b, qwen3-32b, etc.) @@ -190,6 +194,10 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]: 300.0 >>> get_reasoning_stale_timeout_floor("deepseek/deepseek-r1") 600.0 + >>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-flash") + 600.0 + >>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-pro") + 600.0 >>> get_reasoning_stale_timeout_floor("qwen/qwen3-235b-a22b-thinking") 180.0 >>> get_reasoning_stale_timeout_floor("x-ai/grok-4-fast-reasoning") diff --git a/agent/replay_cleanup.py b/agent/replay_cleanup.py index 84815d756fab..19ed38e059b7 100644 --- a/agent/replay_cleanup.py +++ b/agent/replay_cleanup.py @@ -20,6 +20,9 @@ from __future__ import annotations import logging from typing import Any, Dict, List +from agent.tool_dispatch_helpers import make_tool_result_message +from agent.tool_result_classification import tool_may_have_side_effect + logger = logging.getLogger(__name__) @@ -64,8 +67,40 @@ def strip_interrupted_tool_tails( is_interrupted_tool_result(m.get("content", "")) for m in tool_results ): + calls = msg.get("tool_calls") or [] + if any( + tool_may_have_side_effect( + str((call.get("function") or {}).get("name") or "") + ) + for call in calls + ): + call_names = { + str(call.get("id") or call.get("call_id") or ""): str( + (call.get("function") or {}).get("name") or "" + ) + for call in calls + } + cleaned.append(msg) + for tool_result in tool_results: + if not is_interrupted_tool_result(tool_result.get("content", "")): + cleaned.append(tool_result) + continue + recovered = dict(tool_result) + name = call_names.get(str(tool_result.get("tool_call_id") or ""), "") + recovered["effect_disposition"] = ( + "unknown" if tool_may_have_side_effect(name) else "none" + ) + recovered["content"] = ( + "[Orphan recovery: interrupted side-effecting tool may have " + "executed; its effect is UNKNOWN. Inspect state before retrying.]" + if recovered["effect_disposition"] == "unknown" + else "[Orphan recovery: interrupted read-only tool did not complete.]" + ) + cleaned.append(recovered) + i = j + continue logger.debug( - "Stripping interrupted assistant→tool replay block " + "Stripping interrupted read-only assistant→tool replay block " "(indices %d–%d, tool_results=%d)", i, j - 1, len(tool_results), ) @@ -116,11 +151,36 @@ def strip_dangling_tool_call_tail( ): return agent_history + tool_calls = last.get("tool_calls") or [] + if any( + tool_may_have_side_effect( + str((call.get("function") or {}).get("name") or "") + ) + for call in tool_calls + ): + recovered = list(agent_history) + for call in tool_calls: + function = call.get("function") or {} + name = str(function.get("name") or "unknown") + call_id = str(call.get("id") or call.get("call_id") or "") + disposition = "unknown" if tool_may_have_side_effect(name) else "none" + content = ( + "[Orphan recovery: this tool may have executed before Hermes stopped; " + "its effect is UNKNOWN. Inspect current state before retrying.]" + if disposition == "unknown" + else "[Orphan recovery: this read-only tool did not complete and had no effect.]" + ) + recovered.append(make_tool_result_message( + name, content, call_id, effect_disposition=disposition, + )) + logger.warning( + "Recovered dangling side-effecting tool call(s) as UNKNOWN instead of erasing them" + ) + return recovered + logger.debug( - "Stripping dangling unanswered assistant(tool_calls) tail " - "(%d call(s)) — process likely killed mid-tool-call by a " - "restart/shutdown command (#49201)", - len(last.get("tool_calls") or []), + "Stripping dangling unanswered read-only assistant(tool_calls) tail (%d call(s))", + len(tool_calls), ) return agent_history[:-1] diff --git a/agent/skill_commands.py b/agent/skill_commands.py index f4f470c4c014..11d425b91a52 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -329,6 +329,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: try: from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files + from hermes_cli.commands import resolve_command disabled = _get_disabled_skill_names() seen_names: set = set() @@ -374,7 +375,32 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: cmd_name = _SKILL_MULTI_HYPHEN.sub('-', cmd_name).strip('-') if not cmd_name: continue - _skill_commands[f"/{cmd_name}"] = { + # Skip if this skill's auto-generated /command collides + # with a core Hermes slash command (name or alias). The + # skill remains fully loadable via /skill . + # Uses resolve_command() so aliases and case variants are + # covered without maintaining a separate cache. + if resolve_command(cmd_name) is not None: + logger.warning( + "Skill %r generates slash command '/%s' which " + "collides with a core Hermes command; skipping " + "auto-registration. Use '/skill %s' instead.", + name, cmd_name, name, + ) + continue + # Dedup on the resolved slug, not just the raw name: two + # distinct frontmatter names can normalize to the same + # slug (e.g. "git_helper" vs "git-helper"). First-wins + # preserves local-before-external precedence. + cmd_key = f"/{cmd_name}" + if cmd_key in _skill_commands: + logger.warning( + "Skill %r maps to slash command %s already claimed " + "by %r; keeping the first and skipping this one.", + name, cmd_key, _skill_commands[cmd_key]["name"], + ) + continue + _skill_commands[cmd_key] = { "name": name, "description": description or f"Invoke the {name} skill", "skill_md_path": str(skill_md), diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 23c8d99c997d..d07bb5324e0a 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -160,27 +160,8 @@ def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]: # ── Platform matching ───────────────────────────────────────────────────── -def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: - """Return True when the skill is compatible with the current OS. - - Skills declare platform requirements via a top-level ``platforms`` list - in their YAML frontmatter:: - - platforms: [macos] # macOS only - platforms: [macos, linux] # macOS and Linux - - If the field is absent or empty the skill is compatible with **all** - platforms (backward-compatible default). - - Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on - older Pythons but became ``"android"`` on Python 3.13+. Termux is a - Linux userland riding on the Android kernel, so skills tagged - ``linux`` are treated as compatible in Termux regardless of which - ``sys.platform`` value Python reports. Individual Linux commands - inside a skill may still misbehave (no systemd, BusyBox utils, no - apt/dnf, etc.) but that is on the skill, not on platform gating. - """ - platforms = frontmatter.get("platforms") +def skill_matches_platform_list(platforms: Any) -> bool: + """Return True when *platforms* is compatible with the current OS.""" if not platforms: return True if not isinstance(platforms, list): @@ -204,6 +185,29 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: return False +def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: + """Return True when the skill is compatible with the current OS. + + Skills declare platform requirements via a top-level ``platforms`` list + in their YAML frontmatter:: + + platforms: [macos] # macOS only + platforms: [macos, linux] # macOS and Linux + + If the field is absent or empty the skill is compatible with **all** + platforms (backward-compatible default). + + Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on + older Pythons but became ``"android"`` on Python 3.13+. Termux is a + Linux userland riding on the Android kernel, so skills tagged + ``linux`` are treated as compatible in Termux regardless of which + ``sys.platform`` value Python reports. Individual Linux commands + inside a skill may still misbehave (no systemd, BusyBox utils, no + apt/dnf, etc.) but that is on the skill, not on platform gating. + """ + return skill_matches_platform_list(frontmatter.get("platforms")) + + # ── Environment matching ────────────────────────────────────────────────── # Recognized environment tags and how each is detected. An environment tag is @@ -787,8 +791,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str): ``SKILL.md`` files, but they are progressive-disclosure data loaded through ``skill_view(..., file_path=...)`` rather than active skill roots. """ - matches = [] - for root, dirs, files in os.walk(skills_dir, followlinks=True): + skills_dir_str = str(skills_dir) + matches: list[str] = [] + for root, dirs, files in os.walk(skills_dir_str, followlinks=True): has_skill_md = "SKILL.md" in files dirs[:] = [ d @@ -797,9 +802,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str): and not (has_skill_md and d in SKILL_SUPPORT_DIRS) ] if filename in files: - matches.append(Path(root) / filename) - for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))): - yield path + matches.append(os.path.join(root, filename)) + for path in sorted(matches): + yield Path(path) # ── Namespace helpers for plugin-provided skills ─────────────────────────── diff --git a/agent/system_prompt.py b/agent/system_prompt.py index ea33df54a0d0..17959af3c510 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -40,6 +40,7 @@ from agent.prompt_builder import ( SKILLS_GUIDANCE, STEER_CHANNEL_NOTE, TASK_COMPLETION_GUIDANCE, + TELEGRAM_RICH_MESSAGES_HINT, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, drain_truncation_warnings, @@ -429,6 +430,20 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) except Exception: pass + # For Telegram: append the rich-messages extension only when the user has + # opted in to ``platforms.telegram.extra.rich_messages: true``. The base + # hint covers MarkdownV2-compatible constructs; the extension adds Bot API + # 10.1 guidance (tables, task lists, math, collapsible details, etc.). + if platform_key == "telegram" and _default_hint: + try: + from hermes_cli.config import load_config_readonly + _cfg = load_config_readonly() + _tg_extra = ((_cfg.get("platforms") or {}).get("telegram") or {}).get("extra") or {} + if _tg_extra.get("rich_messages"): + _default_hint = _default_hint.rstrip() + " " + TELEGRAM_RICH_MESSAGES_HINT + except Exception: + pass # Config read failure — fall back to base hint only + _effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint) if platform_key == "tui" and _effective_hint: _effective_hint = _tui_embedded_pane_clarifier(_effective_hint) diff --git a/agent/think_scrubber.py b/agent/think_scrubber.py index 44ddcacff704..374035e99767 100644 --- a/agent/think_scrubber.py +++ b/agent/think_scrubber.py @@ -208,19 +208,29 @@ class StreamingThinkScrubber: discarded — leaking partial reasoning is worse than a truncated answer. Otherwise the held-back partial-tag tail is emitted verbatim (it turned out not to be a real tag prefix). + + Always treats the next ``feed()`` as a fresh stream boundary. + Intra-turn retries (thinking-only prefill, empty-response + retry) flush then stream again without calling ``reset()``; + leaving ``_last_emitted_ended_newline`` False made a new + stream's opening ```` look mid-line and leak into the + visible reply. """ if self._in_block: self._buf = "" self._in_block = False + # Next feed() is a new stream — start-of-stream is a boundary. + self._last_emitted_ended_newline = True return "" tail = self._buf self._buf = "" + # Same for the non-block path: do NOT derive the boundary flag + # from the flushed tail (e.g. a held-back '<'). End-of-stream + # means the next feed() starts a new model response. + self._last_emitted_ended_newline = True if not tail: return "" - tail = self._strip_orphan_close_tags(tail) - if tail: - self._last_emitted_ended_newline = tail.endswith("\n") - return tail + return self._strip_orphan_close_tags(tail) # ── internal helpers ─────────────────────────────────────────────── diff --git a/agent/tool_dispatch_helpers.py b/agent/tool_dispatch_helpers.py index 5c9db408b1d8..911cf5fe639b 100644 --- a/agent/tool_dispatch_helpers.py +++ b/agent/tool_dispatch_helpers.py @@ -34,6 +34,7 @@ from typing import Any, Dict, List, Optional from agent.tool_result_classification import ( FILE_MUTATING_TOOL_NAMES as _FILE_MUTATING_TOOLS, ) +from tools.threat_patterns import scan_for_threats logger = logging.getLogger(__name__) @@ -101,50 +102,119 @@ def _is_mcp_tool_parallel_safe(tool_name: str) -> bool: return False -def _should_parallelize_tool_batch(tool_calls) -> bool: - """Return True when a tool-call batch is safe to run concurrently.""" - if len(tool_calls) <= 1: - return False +def _plan_tool_batch_segments(tool_calls) -> List[tuple]: + """Split a tool-call batch into ordered ``(kind, calls)`` segments. - tool_names = [tc.function.name for tc in tool_calls] - if any(name in _NEVER_PARALLEL_TOOLS for name in tool_names): - return False + ``kind`` is ``"parallel"`` (a maximal contiguous run of parallel-safe + calls) or ``"sequential"`` (one or more barrier calls that must run + in-order on the sequential path). Segments preserve the model's + original call order exactly — a later call never crosses an earlier + barrier — so tool-result ordering and side-effect boundaries are + identical to fully-sequential execution. The per-call safety rules + are the same ones the old all-or-nothing gate applied to the whole + batch: + * ``_NEVER_PARALLEL_TOOLS`` (interactive tools) → barrier. + * Unparseable / non-dict arguments → barrier. + * Path-scoped tools (``read_file``/``write_file``/``patch``) join a + parallel run only when their target path does not overlap another + path already reserved in the same run; an overlap closes the run so + the conflicting call starts a NEW run after the first completes. + * Anything not in ``_PARALLEL_SAFE_TOOLS`` and not an opted-in MCP + tool → barrier. + + Parallel runs shorter than two calls are demoted to sequential (no + concurrency win, and the sequential executor owns the richer inline + dispatch), and adjacent sequential segments are merged. + """ + segments: list[list] = [] # [kind, calls] pairs, normalized to tuples on return + current: list = [] reserved_paths: list[Path] = [] + + def _close_parallel() -> None: + nonlocal current, reserved_paths + if current: + segments.append(["parallel", current]) + current = [] + reserved_paths = [] + + def _add_sequential(tc) -> None: + _close_parallel() + if segments and segments[-1][0] == "sequential": + segments[-1][1].append(tc) + else: + segments.append(["sequential", [tc]]) + for tool_call in tool_calls: tool_name = tool_call.function.name + + if tool_name in _NEVER_PARALLEL_TOOLS: + _add_sequential(tool_call) + continue + try: function_args = json.loads(tool_call.function.arguments) except Exception: + _raw = tool_call.function.arguments logging.debug( - "Could not parse args for %s — defaulting to sequential; raw=%s", + "Could not parse args for %s — treating as sequential barrier; raw=%s", tool_name, - tool_call.function.arguments[:200], + _raw[:200] if isinstance(_raw, str) else repr(_raw)[:200], ) - return False + _add_sequential(tool_call) + continue if not isinstance(function_args, dict): logging.debug( - "Non-dict args for %s (%s) — defaulting to sequential", + "Non-dict args for %s (%s) — treating as sequential barrier", tool_name, type(function_args).__name__, ) - return False + _add_sequential(tool_call) + continue if tool_name in _PATH_SCOPED_TOOLS: scoped_path = _extract_parallel_scope_path(tool_name, function_args) if scoped_path is None: - return False + _add_sequential(tool_call) + continue if any(_paths_overlap(scoped_path, existing) for existing in reserved_paths): - return False + # Same-subtree conflict inside this run: close it so this + # call starts a fresh run AFTER the conflicting one lands. + _close_parallel() reserved_paths.append(scoped_path) + current.append(tool_call) continue - if tool_name not in _PARALLEL_SAFE_TOOLS: - # Check if it's an MCP tool from a server that opted into parallel calls. - if not _is_mcp_tool_parallel_safe(tool_name): - return False + if tool_name in _PARALLEL_SAFE_TOOLS or _is_mcp_tool_parallel_safe(tool_name): + current.append(tool_call) + continue - return True + _add_sequential(tool_call) + + _close_parallel() + + normalized: list[list] = [] + for kind, calls in segments: + if kind == "parallel" and len(calls) < 2: + kind = "sequential" + if normalized and normalized[-1][0] == "sequential" and kind == "sequential": + normalized[-1][1].extend(calls) + else: + normalized.append([kind, calls]) + return [(kind, calls) for kind, calls in normalized] + + +def _should_parallelize_tool_batch(tool_calls) -> bool: + """Return True when the WHOLE tool-call batch is safe to run concurrently. + + Thin view over ``_plan_tool_batch_segments`` kept for callers/tests that + only care about the homogeneous case: True iff the planner produces a + single all-parallel segment. + """ + if len(tool_calls) <= 1: + return False + segments = _plan_tool_batch_segments(tool_calls) + return len(segments) == 1 and segments[0][0] == "parallel" def _extract_parallel_scope_path(tool_name: str, function_args: dict) -> Optional[Path]: @@ -358,7 +428,13 @@ def _trajectory_normalize_msg(msg: Dict[str, Any]) -> Dict[str, Any]: return msg -def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict: +def make_tool_result_message( + name: str, + content: Any, + tool_call_id: str, + *, + effect_disposition: str | None = None, +) -> dict: """Build a tool-result message dict with both the OpenAI-format ``name`` field (required by the wire format and provider adapters) and the internal ``tool_name`` field (written to the session DB messages table). @@ -379,13 +455,23 @@ def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict callers should compare by value, not by ``is``. """ wrapped = _maybe_wrap_untrusted(name, content) - return { + message = { "role": "tool", "name": name, "tool_name": name, "content": wrapped, "tool_call_id": tool_call_id, } + try: + risk_metadata = _tool_output_risk_metadata(name, content) + except Exception as exc: + logger.debug("Tool output risk scan failed for %s: %s", name, exc) + else: + if risk_metadata is not None: + message["_tool_output_risk"] = risk_metadata + if effect_disposition is not None: + message["effect_disposition"] = effect_disposition + return message # Tools whose results carry attacker-controllable content. Wrapping their @@ -419,6 +505,42 @@ def _is_untrusted_tool(name: Optional[str]) -> bool: return any(name.startswith(p) for p in _UNTRUSTED_TOOL_PREFIXES) +def _tool_output_risk_metadata(name: str, content: Any) -> Optional[Dict[str, Any]]: + """Classify textual attacker-controlled output without retaining a copy. + + The advisory metadata is internal-only. It records deterministic finding + identifiers, never blocks or redacts the normal result, and deliberately + omits raw scanned text. + """ + if not _is_untrusted_tool(name): + return None + if isinstance(content, str): + text_parts = [content] + elif isinstance(content, list): + text_parts = [ + item["text"] + for item in content + if isinstance(item, dict) + and item.get("type") == "text" + and isinstance(item.get("text"), str) + ] + if not text_parts: + return None + else: + return None + + findings: List[str] = [] + for text in text_parts: + for finding in scan_for_threats(text, scope="context"): + if finding not in findings: + findings.append(finding) + return { + "risk": "high" if findings else "low", + "findings": findings, + "redacted": False, + } + + def _neutralize_delimiters(content: str) -> str: """Defang any literal ``untrusted_tool_result`` delimiter embedded in attacker-controlled content so it can't break out of the wrapper. @@ -489,6 +611,7 @@ __all__ = [ "_DESTRUCTIVE_PATTERNS", "_REDIRECT_OVERWRITE", "_is_destructive_command", + "_plan_tool_batch_segments", "_should_parallelize_tool_batch", "_extract_parallel_scope_path", "_paths_overlap", diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 9688f5d3dc6a..a5871442b449 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -36,6 +36,7 @@ from agent.tool_dispatch_helpers import ( _is_multimodal_tool_result, _multimodal_text_summary, _append_subdir_hint_to_multimodal, + _plan_tool_batch_segments, make_tool_result_message, ) from tools.terminal_tool import ( @@ -74,6 +75,25 @@ _MAX_TOOL_WORKERS = 8 _DEFAULT_CONCURRENT_TOOL_TIMEOUT_S = 420.0 +def _parse_tool_arguments(raw_arguments: Any) -> tuple[dict, Optional[str]]: + """Parse model-emitted arguments without repairing or coercing them.""" + try: + arguments = json.loads(raw_arguments) + except (json.JSONDecodeError, TypeError): + arguments = None + if isinstance(arguments, dict): + return arguments, None + return {}, json.dumps( + { + "error": "Invalid tool arguments", + "message": ( + "Tool arguments must be a valid JSON object; tool was not executed." + ), + }, + ensure_ascii=False, + ) + + def _resolve_concurrent_tool_timeout() -> float | None: raw = os.getenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "").strip() if not raw: @@ -303,11 +323,15 @@ def _run_agent_tool_execution_middleware( return result, observed_args -def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: +def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, *, finalize: bool = True) -> None: """Execute multiple tool calls concurrently using a thread pool. Results are collected in the original tool-call order and appended to messages so the API sees them in the expected sequence. + + ``finalize=False`` skips the end-of-batch aggregate budget enforcement + and /steer injection — used when this call is one segment of a larger + mixed batch and the segmented dispatcher owns the turn-end work. """ tool_calls = assistant_message.tool_calls num_tools = len(tool_calls) @@ -324,6 +348,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe tc.function.name, f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]", tc.id, + effect_disposition="none", )) _flush_session_db_after_tool_progress( agent, @@ -337,19 +362,29 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe for tool_call in tool_calls: function_name = tool_call.function.name - # Reset nudge counters + function_args, malformed_args_result = _parse_tool_arguments( + tool_call.function.arguments + ) + + if malformed_args_result is not None: + parsed_calls.append( + ( + tool_call, + function_name, + function_args, + [], + malformed_args_result, + False, + ) + ) + continue + + # Reset nudge counters only for a structurally valid invocation. if function_name == "memory": agent._turns_since_memory = 0 elif function_name == "skill_manage": agent._iters_since_skill = 0 - try: - function_args = json.loads(tool_call.function.arguments) - except json.JSONDecodeError: - function_args = {} - if not isinstance(function_args, dict): - function_args = {} - # ── Tool Search unwrap ──────────────────────────────────────── # When the model invokes the tool_call bridge, peel it open so # every downstream check (checkpointing, guardrails, plugin @@ -798,9 +833,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # deadline snapshot (timed_out_indices, taken from not_done) and this # loop. Prefer that real result over a fabricated timeout message — the # tool genuinely succeeded, just slightly late. + effect_disposition = None if i in timed_out_indices and r is None: suffix = f"{timeout_s:.1f}s" if timeout_s is not None else "the configured timeout" function_result = f"Error executing tool '{name}': timed out after {suffix}" + effect_disposition = "unknown" _emit_terminal_post_tool_call( agent, function_name=name, @@ -847,6 +884,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe tool_duration = 0.0 else: function_name, function_args, function_result, tool_duration, is_error, blocked, middleware_trace = r + if blocked: + effect_disposition = "none" if not blocked: function_result = agent._append_guardrail_observation( @@ -935,7 +974,30 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # image tool result never poisons canonical session history. # String results pass through unchanged. _tool_content = agent._tool_result_content_for_active_model(name, function_result) - messages.append(make_tool_result_message(name, _tool_content, tc.id)) + tool_message = make_tool_result_message( + name, + _tool_content, + tc.id, + effect_disposition=effect_disposition, + ) + messages.append(tool_message) + risk_metadata = tool_message.get("_tool_output_risk") + if ( + risk_metadata is not None + and risk_metadata.get("risk") != "low" + and agent.tool_progress_callback + ): + try: + agent.tool_progress_callback( + "tool.output_risk", + name, + None, + None, + tool_call_id=tc.id, + risk_metadata=risk_metadata, + ) + except Exception as cb_err: + logging.debug("Tool output risk callback error: %s", cb_err) _flush_session_db_after_tool_progress( agent, messages, @@ -949,7 +1011,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # ── Per-turn aggregate budget enforcement ───────────────────────── num_tools = len(parsed_calls) - if num_tools > 0: + if finalize and num_tools > 0: turn_tool_msgs = messages[-num_tools:] enforce_turn_budget(turn_tool_msgs, env=get_active_env(effective_task_id), config=_tool_budget) @@ -957,13 +1019,18 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # Append any pending user steer text to the last tool result so the # agent sees it on its next iteration. Runs AFTER budget enforcement # so the steer marker is never truncated. See steer() for details. - if num_tools > 0: + if finalize and num_tools > 0: agent._apply_pending_steer_to_tool_results(messages, num_tools) -def execute_tool_calls_sequential(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: - """Execute tool calls sequentially (original behavior). Used for single calls or interactive tools.""" +def execute_tool_calls_sequential(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, *, finalize: bool = True) -> None: + """Execute tool calls sequentially (original behavior). Used for single calls or interactive tools. + + ``finalize=False`` skips the end-of-batch aggregate budget enforcement + and /steer injection — used when this call is one segment of a larger + mixed batch and the segmented dispatcher owns the turn-end work. + """ # Resolve the context-scaled tool-output budget once per turn. _tool_budget = _budget_for_agent(agent) for i, tool_call in enumerate(assistant_message.tool_calls, 1): @@ -980,6 +1047,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe skipped_name, f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]", skipped_tc.id, + effect_disposition="none", )) _flush_session_db_after_tool_progress( agent, @@ -990,13 +1058,24 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe function_name = tool_call.function.name - try: - function_args = json.loads(tool_call.function.arguments) - except json.JSONDecodeError as e: - logger.warning(f"Unexpected JSON error after validation: {e}") - function_args = {} - if not isinstance(function_args, dict): - function_args = {} + function_args, malformed_args_result = _parse_tool_arguments( + tool_call.function.arguments + ) + if malformed_args_result is not None: + messages.append( + make_tool_result_message( + function_name, + malformed_args_result, + tool_call.id, + ) + ) + _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"invalid tool arguments {function_name}", + ) + agent._apply_pending_steer_to_tool_results(messages, 1) + continue # Tool Search unwrap — see execute_tool_calls_concurrent for full # rationale, including the scope gate (the unwrap dispatches the @@ -1584,7 +1663,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe # Unwrap _multimodal dicts to an OpenAI-style content list # (see parallel path for rationale). String results pass through. _tool_content = agent._tool_result_content_for_active_model(function_name, function_result) - messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id)) + tool_message = make_tool_result_message(function_name, _tool_content, tool_call.id) + messages.append(tool_message) + risk_metadata = tool_message.get("_tool_output_risk") + if ( + risk_metadata is not None + and risk_metadata.get("risk") != "low" + and agent.tool_progress_callback + ): + try: + agent.tool_progress_callback( + "tool.output_risk", + function_name, + None, + None, + tool_call_id=tool_call.id, + risk_metadata=risk_metadata, + ) + except Exception as cb_err: + logging.debug("Tool output risk callback error: %s", cb_err) _flush_session_db_after_tool_progress( agent, messages, @@ -1615,6 +1712,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe skipped_name, f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]", skipped_tc.id, + effect_disposition="none", )) _flush_session_db_after_tool_progress( agent, @@ -1628,19 +1726,73 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe # ── Per-turn aggregate budget enforcement ───────────────────────── num_tools_seq = len(assistant_message.tool_calls) - if num_tools_seq > 0: + if finalize and num_tools_seq > 0: enforce_turn_budget(messages[-num_tools_seq:], env=get_active_env(effective_task_id), config=_tool_budget) # ── /steer injection ────────────────────────────────────────────── # See _execute_tool_calls_parallel for the rationale. Same hook, # applied to sequential execution as well. - if num_tools_seq > 0: + if finalize and num_tools_seq > 0: agent._apply_pending_steer_to_tool_results(messages, num_tools_seq) +def execute_tool_calls_segmented(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, segments=None) -> None: + """Execute a mixed tool-call batch as ordered parallel/sequential segments. + + ``segments`` is the ``(kind, calls)`` plan from + ``_plan_tool_batch_segments``: maximal contiguous runs of parallel-safe + calls execute on the concurrent path, barrier calls on the sequential + path, strictly in the model's original call order. Because segments are + contiguous, every tool result is still appended one-per-call in emission + order and no call ever starts before an earlier barrier finishes — + identical ordering and side-effect boundaries to fully-sequential + execution, with I/O parallelism recovered inside the safe runs. + + Turn-end work (aggregate budget enforcement + /steer injection) is done + once here for the WHOLE batch; the per-segment executor calls run with + ``finalize=False`` so a multi-segment turn cannot multiply the budget or + truncate a steer marker. + + Interrupt semantics: each segment executor already checks + ``agent._interrupt_requested`` up front and appends a cancelled/skipped + result per call, so an interrupt during segment *k* drains segments + *k+1..n* without executing them while preserving one result per + tool_call_id. + """ + from types import SimpleNamespace + + if segments is None: + segments = _plan_tool_batch_segments(assistant_message.tool_calls) + + for kind, calls in segments: + segment_message = SimpleNamespace(tool_calls=list(calls)) + if kind == "parallel": + execute_tool_calls_concurrent( + agent, segment_message, messages, effective_task_id, api_call_count, + finalize=False, + ) + else: + execute_tool_calls_sequential( + agent, segment_message, messages, effective_task_id, api_call_count, + finalize=False, + ) + + # ── Whole-turn finalize (budget + /steer) ───────────────────────── + total_tools = len(assistant_message.tool_calls) + if total_tools > 0: + _tool_budget = _budget_for_agent(agent) + enforce_turn_budget( + messages[-total_tools:], + env=get_active_env(effective_task_id), + config=_tool_budget, + ) + agent._apply_pending_steer_to_tool_results(messages, total_tools) + + __all__ = [ "execute_tool_calls_concurrent", "execute_tool_calls_sequential", + "execute_tool_calls_segmented", ] diff --git a/agent/tool_result_classification.py b/agent/tool_result_classification.py index e136e2964da8..c71fc6c31db1 100644 --- a/agent/tool_result_classification.py +++ b/agent/tool_result_classification.py @@ -9,6 +9,20 @@ from typing import Any FILE_MUTATING_TOOL_NAMES = frozenset({"write_file", "patch"}) +# Tools whose interrupted/dangling execution is safe to discard because they +# cannot mutate either external state or Hermes session state. Unknown/plugin/ +# MCP tools stay effect-capable by default. +NO_EFFECT_TOOL_NAMES = frozenset({ + "read_file", "search_files", "session_search", "skill_view", "skills_list", + "web_extract", "web_search", "vision_analyze", "browser_snapshot", + "browser_get_images", "browser_console", "read_terminal", +}) + + +def tool_may_have_side_effect(tool_name: str) -> bool: + return tool_name not in NO_EFFECT_TOOL_NAMES + + def file_mutation_result_landed(tool_name: str, result: Any) -> bool: """Return True when a file mutation result proves the write landed.""" if tool_name not in FILE_MUTATING_TOOL_NAMES or not isinstance(result, str): diff --git a/agent/transcription_registry.py b/agent/transcription_registry.py index d84f93b19e46..b04a8593a576 100644 --- a/agent/transcription_registry.py +++ b/agent/transcription_registry.py @@ -44,6 +44,8 @@ _BUILTIN_NAMES = frozenset({ "openai", "mistral", "xai", + "elevenlabs", + "deepinfra", }) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index ff2cdcbaee69..8d94ecbcbedd 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -9,7 +9,6 @@ which has provider-specific conditionals for max_tokens defaults, reasoning configuration, temperature handling, and extra_body assembly. """ -import copy from typing import Any, Dict from agent.lmstudio_reasoning import resolve_lmstudio_effort @@ -19,6 +18,20 @@ from agent.transports.base import ProviderTransport from agent.transports.types import NormalizedResponse, ToolCall, Usage +def _reasoning_config_for_model(model: str, reasoning_config: dict | None) -> dict | None: + """Return the model's wire-compatible reasoning config.""" + if not isinstance(reasoning_config, dict): + return reasoning_config + if ( + "gpt-5.6" in (model or "").lower() + and str(reasoning_config.get("effort") or "").strip().lower() == "ultra" + ): + normalized = dict(reasoning_config) + normalized["effort"] = "max" + return normalized + return reasoning_config + + def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> dict | None: """Translate Hermes/OpenRouter-style reasoning config to Gemini thinkingConfig.""" if reasoning_config is None or not isinstance(reasoning_config, dict): @@ -53,7 +66,7 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> if normalized_model.startswith("gemini-2.5-"): return thinking_config - if effort not in {"minimal", "low", "medium", "high", "xhigh"}: + if effort not in {"minimal", "low", "medium", "high", "xhigh", "max", "ultra"}: effort = "medium" # Gemini 3 Flash documents low/medium/high thinking levels; Gemini 3 Pro @@ -63,13 +76,13 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> if "flash" in normalized_model: if effort in {"minimal", "low"}: thinking_config["thinkingLevel"] = "low" - elif effort in {"high", "xhigh"}: + elif effort in {"high", "xhigh", "max", "ultra"}: thinking_config["thinkingLevel"] = "high" else: thinking_config["thinkingLevel"] = "medium" elif "pro" in normalized_model: thinking_config["thinkingLevel"] = ( - "high" if effort in {"high", "xhigh"} else "low" + "high" if effort in {"high", "xhigh", "max", "ultra"} else "low" ) return thinking_config @@ -172,6 +185,7 @@ class ChatCompletionsTransport(ProviderTransport): "codex_reasoning_items" in msg or "codex_message_items" in msg or "tool_name" in msg + or "effect_disposition" in msg or "timestamp" in msg # #47868 — strict providers reject this ): needs_sanitize = True @@ -195,27 +209,65 @@ class ChatCompletionsTransport(ProviderTransport): if not needs_sanitize: return messages - sanitized = copy.deepcopy(messages) - for msg in sanitized: + sanitized = list(messages) + for msg_idx, msg in enumerate(messages): if not isinstance(msg, dict): continue - msg.pop("codex_reasoning_items", None) - msg.pop("codex_message_items", None) - msg.pop("tool_name", None) - msg.pop("timestamp", None) # #47868 — leak into strict providers + + copied_msg: dict[str, Any] | None = None + + def mutable_msg() -> dict[str, Any]: + nonlocal copied_msg + if copied_msg is None: + copied_msg = dict(msg) + sanitized[msg_idx] = copied_msg + return copied_msg + + if ( + "codex_reasoning_items" in msg + or "codex_message_items" in msg + or "tool_name" in msg + or "effect_disposition" in msg + or "timestamp" in msg # #47868 — leak into strict providers + ): + out_msg = mutable_msg() + out_msg.pop("codex_reasoning_items", None) + out_msg.pop("codex_message_items", None) + out_msg.pop("tool_name", None) + out_msg.pop("effect_disposition", None) + out_msg.pop("timestamp", None) # #47868 — leak into strict providers + + # Drop all Hermes-internal scaffolding markers (``_``-prefixed). # OpenAI's message schema has no ``_``-prefixed fields, so this # is safe and future-proofs against new markers being added. - for key in [k for k in msg if isinstance(k, str) and k.startswith("_")]: - msg.pop(key, None) + internal_keys = [k for k in msg if isinstance(k, str) and k.startswith("_")] + if internal_keys: + out_msg = mutable_msg() + for key in internal_keys: + out_msg.pop(key, None) + tool_calls = msg.get("tool_calls") if isinstance(tool_calls, list): - for tc in tool_calls: + copied_tool_calls: list[Any] | None = None + for tc_idx, tc in enumerate(tool_calls): if isinstance(tc, dict): - tc.pop("call_id", None) - tc.pop("response_item_id", None) - if strip_extra_content: - tc.pop("extra_content", None) + should_copy_tc = ( + "call_id" in tc + or "response_item_id" in tc + or (strip_extra_content and "extra_content" in tc) + ) + if should_copy_tc: + if copied_tool_calls is None: + copied_tool_calls = list(tool_calls) + copied_tc = dict(tc) + copied_tc.pop("call_id", None) + copied_tc.pop("response_item_id", None) + if strip_extra_content: + copied_tc.pop("extra_content", None) + copied_tool_calls[tc_idx] = copied_tc + if copied_tool_calls is not None: + mutable_msg()["tool_calls"] = copied_tool_calls return sanitized def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -326,7 +378,7 @@ class ChatCompletionsTransport(ProviderTransport): is_nvidia_nim = params.get("is_nvidia_nim", False) is_kimi = params.get("is_kimi", False) is_tokenhub = params.get("is_tokenhub", False) - reasoning_config = params.get("reasoning_config") + reasoning_config = _reasoning_config_for_model(model, params.get("reasoning_config")) if ephemeral is not None and max_tokens_fn: api_kwargs.update(max_tokens_fn(ephemeral)) @@ -525,7 +577,7 @@ class ChatCompletionsTransport(ProviderTransport): api_kwargs["max_tokens"] = anthropic_max # Provider-specific api_kwargs extras (reasoning_effort, metadata, etc.) - reasoning_config = params.get("reasoning_config") + reasoning_config = _reasoning_config_for_model(model, params.get("reasoning_config")) extra_body_from_profile, top_level_from_profile = ( profile.build_api_kwargs_extras( reasoning_config=reasoning_config, diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 56374b875335..ef7ffbaffd24 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -67,9 +67,9 @@ class ResponsesApiTransport(ProviderTransport): """Classify the current Responses endpoint from transport params.""" from agent.codex_responses_adapter import _classify_responses_issuer return _classify_responses_issuer( - is_xai_responses=bool(params.get("is_xai_responses")), - is_github_responses=bool(params.get("is_github_responses")), - is_codex_backend=bool(params.get("is_codex_backend")), + is_xai_responses=params.get("is_xai_responses") is True, + is_github_responses=params.get("is_github_responses") is True, + is_codex_backend=params.get("is_codex_backend") is True, base_url=params.get("base_url"), ) @@ -80,7 +80,8 @@ class ResponsesApiTransport(ProviderTransport): self._last_issuer_kind = issuer return _chat_messages_to_responses_input( messages, - is_xai_responses=bool(kwargs.get("is_xai_responses")), + is_xai_responses=kwargs.get("is_xai_responses") is True, + is_github_responses=kwargs.get("is_github_responses") is True, replay_encrypted_reasoning=bool( kwargs.get("replay_encrypted_reasoning", True) ), @@ -137,9 +138,9 @@ class ResponsesApiTransport(ProviderTransport): if not instructions: instructions = DEFAULT_AGENT_IDENTITY - is_github_responses = params.get("is_github_responses", False) - is_codex_backend = params.get("is_codex_backend", False) - is_xai_responses = params.get("is_xai_responses", False) + is_github_responses = params.get("is_github_responses") is True + is_codex_backend = params.get("is_codex_backend") is True + is_xai_responses = params.get("is_xai_responses") is True replay_encrypted_reasoning = bool( params.get("replay_encrypted_reasoning", True) ) @@ -163,6 +164,12 @@ class ResponsesApiTransport(ProviderTransport): reasoning_effort = reasoning_config["effort"] _effort_clamp = {"minimal": "low"} + if "gpt-5.6" in (model or "").lower(): + # Ultra is the Codex product tier; the Responses API wire value is max. + _effort_clamp["ultra"] = "max" + if params.get("is_xai_responses", False): + # xAI Responses tops out at high; keep generic stronger values usable. + _effort_clamp.update({"xhigh": "high", "max": "high", "ultra": "high"}) reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort) response_tools = _responses_tools(tools) @@ -239,6 +246,7 @@ class ResponsesApiTransport(ProviderTransport): "input": _chat_messages_to_responses_input( payload_messages, is_xai_responses=is_xai_responses, + is_github_responses=is_github_responses, replay_encrypted_reasoning=replay_encrypted_reasoning, current_issuer_kind=issuer_kind, ), @@ -427,24 +435,46 @@ class ResponsesApiTransport(ProviderTransport): def validate_response(self, response: Any) -> bool: """Check Codex Responses API response has valid output structure. - Returns True only if response.output is a non-empty list. - Does NOT check output_text fallback — the caller handles that - with diagnostic logging for stream backfill recovery. + Returns True only if response.output is a non-empty list. Also treats + terminal content-filter incomplete responses as valid: the Responses API + may return status=incomplete with incomplete_details.reason='content_filter' + and no output items. That is a provider refusal signal, not a malformed + response, and must reach normalization so the agent loop can use the + content-policy / fallback path instead of invalid-response retries. + + Does NOT check output_text fallback — the caller handles that with + diagnostic logging for stream backfill recovery. """ if response is None: return False output = getattr(response, "output", None) if not isinstance(output, list) or not output: - return False + status = str(getattr(response, "status", "") or "").strip().lower() + incomplete_details = getattr(response, "incomplete_details", None) + if isinstance(incomplete_details, dict): + reason = str(incomplete_details.get("reason") or "").strip().lower() + else: + reason = str(getattr(incomplete_details, "reason", "") or "").strip().lower() + return status == "incomplete" and reason == "content_filter" return True - def preflight_kwargs(self, api_kwargs: Any, *, allow_stream: bool = False) -> dict: + def preflight_kwargs( + self, + api_kwargs: Any, + *, + allow_stream: bool = False, + is_github_responses: bool = False, + ) -> dict: """Validate and sanitize Codex API kwargs before the call. Normalizes input items, strips unsupported fields, validates structure. """ from agent.codex_responses_adapter import _preflight_codex_api_kwargs - return _preflight_codex_api_kwargs(api_kwargs, allow_stream=allow_stream) + return _preflight_codex_api_kwargs( + api_kwargs, + allow_stream=allow_stream, + is_github_responses=is_github_responses, + ) def map_finish_reason(self, raw_reason: str) -> str: """Map Codex response.status to OpenAI finish_reason. diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index 78af728711dd..e7600f3be0dd 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -755,22 +755,22 @@ class CodexAppServerSession: turn_obj = (note.get("params") or {}).get("turn") or {} result.turn_id = turn_obj.get("id") or result.turn_id turn_status = turn_obj.get("status") - if turn_status and turn_status not in {"completed", "interrupted"}: + if turn_status == "interrupted": + result.interrupted = True + result.error = result.error or "compact turn interrupted" + elif turn_status and turn_status != "completed": err_obj = turn_obj.get("error") - if err_obj: - err_msg = _format_responses_error(err_obj, str(turn_status)) - stderr_blob = "\n".join( - self._client.stderr_tail(40) + err_msg = _format_responses_error(err_obj, str(turn_status)) + stderr_blob = "\n".join(self._client.stderr_tail(40)) + hint = _classify_oauth_failure(err_msg, stderr_blob) + if hint is not None: + result.error = hint + result.should_retire = True + else: + result.error = self._format_error_with_stderr( + f"compact turn ended status={turn_status}", + err_msg, ) - hint = _classify_oauth_failure(err_msg, stderr_blob) - if hint is not None: - result.error = hint - result.should_retire = True - else: - result.error = self._format_error_with_stderr( - f"compact turn ended status={turn_status}", - err_msg, - ) if not turn_complete and not result.interrupted: self._issue_interrupt(result.turn_id) diff --git a/agent/transports/hermes_tools_mcp_server.py b/agent/transports/hermes_tools_mcp_server.py index 37f2d6179d11..7fc23cf506d9 100644 --- a/agent/transports/hermes_tools_mcp_server.py +++ b/agent/transports/hermes_tools_mcp_server.py @@ -44,6 +44,7 @@ Spawned by: CodexAppServerSession.ensure_started() when the runtime is from __future__ import annotations +import inspect import json import logging import os @@ -52,6 +53,49 @@ from typing import Any, Optional logger = logging.getLogger(__name__) +# JSON Schema type -> Python type mapping for signature generation +_JSON_TO_PY = { + "string": str, + "integer": int, + "number": float, + "boolean": bool, + "array": list, + "object": dict, +} + + +def _signature_from_schema(schema: dict | None) -> tuple[inspect.Signature, dict[str, type]]: + """Build a Python function signature and annotations from a JSON schema. + + Args: + schema: JSON Schema dict with "properties" and "required" keys. + + Returns: + (signature, annotations_dict) where signature has KEYWORD_ONLY params + and annotations maps param names to Python types. + """ + props = (schema or {}).get("properties") or {} + required = set((schema or {}).get("required") or []) + params, annots = [], {} + + for pname, pspec in props.items(): + if pname.startswith("_"): + continue + py = _JSON_TO_PY.get((pspec or {}).get("type"), Any) + ann, default = ( + (py, inspect.Parameter.empty) + if pname in required + else (Optional[py], None) + ) + annots[pname] = ann + params.append( + inspect.Parameter( + pname, inspect.Parameter.KEYWORD_ONLY, annotation=ann, default=default + ) + ) + + return inspect.Signature(params, return_annotation=str), annots + # Tools we expose. Each name MUST match a registered Hermes tool that # `model_tools.handle_function_call()` can dispatch. @@ -159,29 +203,36 @@ def _build_server() -> Any: # the result string. We use add_tool() for full control over the # input schema (FastMCP's @tool() decorator inspects type hints, # which we can't get from a JSON schema at runtime). - def _make_handler(tool_name: str): + def _make_handler(tool_name: str, schema: dict | None): + sig, annots = _signature_from_schema(schema) + def _dispatch(**kwargs: Any) -> str: try: - return handle_function_call(tool_name, kwargs or {}) + # Filter out None values before dispatch so unset optionals + # aren't forwarded to the handler. + args = {k: v for k, v in kwargs.items() if v is not None} + return handle_function_call(tool_name, args or {}) except Exception as exc: logger.exception("tool %s raised", tool_name) return json.dumps({"error": str(exc), "tool": tool_name}) + _dispatch.__name__ = tool_name _dispatch.__doc__ = description + _dispatch.__signature__ = sig + _dispatch.__annotations__ = {**annots, "return": str} return _dispatch try: mcp.add_tool( - _make_handler(name), + _make_handler(name, params_schema), name=name, description=description, - # FastMCP accepts JSON schema directly via the - # input_schema parameter on newer versions; older - # versions use parameters_schema. Try both for compat. ) except TypeError: - # Older mcp SDK signature — fall back to decorator-style. - handler = _make_handler(name) + # Older mcp SDK signature — fall back to decorator-style. The + # synthesized __signature__ on the handler still drives schema + # generation there. + handler = _make_handler(name, params_schema) handler = mcp.tool(name=name, description=description)(handler) exposed_count += 1 diff --git a/agent/tts_registry.py b/agent/tts_registry.py index 7cf6e6cb00ad..a43359ec5958 100644 --- a/agent/tts_registry.py +++ b/agent/tts_registry.py @@ -56,6 +56,7 @@ _BUILTIN_NAMES = frozenset({ "neutts", "kittentts", "piper", + "deepinfra", }) diff --git a/agent/turn_context.py b/agent/turn_context.py index 4fd6ddff2c3e..ea150ff30a7c 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -118,12 +118,12 @@ class TurnContext: def build_turn_context( agent, - user_message: str, + user_message: Any, system_message: Optional[str], conversation_history: Optional[List[Dict[str, Any]]], task_id: Optional[str], stream_callback, - persist_user_message: Optional[str], + persist_user_message: Optional[Any], persist_user_timestamp: Optional[float] = None, *, restore_or_build_system_prompt, @@ -271,6 +271,29 @@ def build_turn_context( # Initialize conversation (copy to avoid mutating the caller's list). messages = list(conversation_history) if conversation_history else [] + # The CLI may already have staged this input outside the history passed to + # ``run_conversation``. Reuse it only when its clean transcript text matches + # this turn; a stale handoff from a failed prior turn must not replace a + # later, different user input. Voice turns compare against their explicit + # clean persistence override rather than the API-only prefixed payload. + pending_cli_message = getattr(agent, "_pending_cli_user_message", None) + expected_persist_content = ( + persist_user_message if persist_user_message is not None else user_message + ) + if ( + isinstance(pending_cli_message, dict) + and pending_cli_message.get("content") == expected_persist_content + ): + user_msg = pending_cli_message + # The CLI-staged value is the clean transcript text. Restore the + # API-facing variant (for example, a voice-mode prefix) while retaining + # the same dict and any close-path durable marker. + user_msg["content"] = user_message + else: + user_msg = {"role": "user", "content": user_message} + if isinstance(pending_cli_message, dict): + agent._pending_cli_user_message = None + # Hydrate todo store from conversation history. if conversation_history and not agent._todo_store.has_items(): agent._hydrate_todo_store(conversation_history) @@ -285,6 +308,13 @@ def build_turn_context( if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0: agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval + # Add the current user message after the prompt/session setup has made + # close persistence safe. The handoff above preserves any marker already + # stamped by an earlier close flush. + messages.append(user_msg) + current_turn_user_idx = len(messages) - 1 + agent._persist_user_message_idx = current_turn_user_idx + # Track user turns for memory flush and periodic nudge logic. agent._user_turn_count += 1 # Copilot x-initiator: the first API call of this user turn is @@ -313,11 +343,19 @@ def build_turn_context( should_review_memory = True agent._turns_since_memory = 0 - # Add user message. - user_msg = {"role": "user", "content": user_message} - messages.append(user_msg) - current_turn_user_idx = len(messages) - 1 - agent._persist_user_message_idx = current_turn_user_idx + # Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot) + # and notify the host so it can play hearts. Token-free, never touches the + # conversation, and never fatal — a purely optional UI beat. + reaction_callback = getattr(agent, "reaction_callback", None) + if reaction_callback is not None: + try: + from agent.reactions import detect_reaction + + kind = detect_reaction(original_user_message) + if kind: + reaction_callback(kind) + except Exception: + pass if not agent.quiet_mode: _print_preview = summarize_user_message_for_log(user_message) @@ -334,18 +372,33 @@ def build_turn_context( # Create the DB session row now that _cached_system_prompt is populated, so # the persisted snapshot is written non-NULL on the first turn (Issue - # #45499). Idempotent: _ensure_db_session() no-ops once the row exists. - agent._ensure_db_session() + # #45499). Keep row creation and the marker-based append in the same + # per-agent critical section as CLI close persistence. + persist_lock = getattr(agent, "_session_persist_lock", None) + + def _ensure_and_persist() -> None: + agent._ensure_db_session() + agent._persist_session(messages, conversation_history) # Crash-resilience: persist the inbound user turn as soon as the session row exists. try: - agent._persist_session(messages, conversation_history) + if persist_lock is None: + _ensure_and_persist() + else: + with persist_lock: + _ensure_and_persist() except Exception: logger.warning( "Early turn-start session persistence failed for session=%s", agent.session_id or "none", exc_info=True, ) + finally: + # Keep an unmarked staged input available to a later close retry if the + # normal persistence attempt failed. Once the marker is present, the + # close path must no longer treat it as a pre-worker UI input. + if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"): + agent._pending_cli_user_message = None # ── Preflight context compression ── # Gate the (expensive) full token estimate behind a cheap pre-check. diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 5eaad31848c7..fdf5babe1aea 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -42,6 +42,7 @@ def finalize_turn( original_user_message, _should_review_memory, _turn_exit_reason, + _pending_verification_response=None, ): """Run the post-loop finalization and return the turn ``result`` dict. @@ -50,10 +51,35 @@ def finalize_turn( """ from agent.conversation_loop import logger - if final_response is None and ( + budget_exhausted = ( api_call_count >= agent.max_iterations or agent.iteration_budget.remaining <= 0 - ): + ) + budget_fallback_eligible = ( + budget_exhausted + and not interrupted + and not failed + and str(_turn_exit_reason) in {"unknown", "budget_exhausted"} + ) + continuation_budget_exhausted = ( + final_response is None + and bool(_pending_verification_response) + and budget_fallback_eligible + ) + + iteration_limit_fallback = False + preserved_verification_fallback = False + if continuation_budget_exhausted: + # A verification/continuation gate deliberately withheld a composed + # answer, then consumed the remaining budget before producing a newer + # one. Preserve that exact answer instead of replacing it with another + # fallible model call. The explicit pending value is the provenance + # guard: unrelated error/recovery exits can never enter this branch. + final_response = _pending_verification_response + _turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})" + iteration_limit_fallback = True + preserved_verification_fallback = True + elif final_response is None and budget_fallback_eligible: # Budget exhausted — ask the model for a summary via one extra # API call with tools stripped. _handle_max_iterations injects a # user message and makes a single toolless request. @@ -68,20 +94,18 @@ def finalize_turn( "— requesting summary..." ) final_response = agent._handle_max_iterations(messages, api_call_count) + iteration_limit_fallback = True + if iteration_limit_fallback: # If running as a kanban worker, signal the dispatcher that the # worker could not complete (rather than treating it as a - # protocol violation). The agent loop strips tools before calling - # _handle_max_iterations, so the model cannot call kanban_block - # itself — we must do it on its behalf. + # protocol violation). This applies whether the user-facing fallback + # came from the summary call or an explicitly pending continuation; + # both exhausted the task budget and must advance the failure circuit. # # We route through ``_record_task_failure(outcome="timed_out")`` - # rather than ``kanban_block`` so this counts toward the - # ``consecutive_failures`` counter and the dispatcher's - # ``failure_limit`` circuit breaker (#29747 gap 2). Without this, - # a task whose worker keeps exhausting its budget would block - # silently each run, get auto-promoted by the operator (or never - # surface), and re-block in an endless loop with no signal. + # rather than ``kanban_block`` so this counts toward the dispatcher's + # consecutive-failure circuit breaker (#29747 gap 2). _kanban_task = os.environ.get("HERMES_KANBAN_TASK") if _kanban_task: try: @@ -204,6 +228,15 @@ def finalize_turn( if _tail_role != "assistant": messages.append({"role": "assistant", "content": final_response}) + # The model has completed its request, so replace API-local + # voice/model/skill guidance with the clean user input before writing the + # final durable snapshot and returning the continuation history. Earlier + # turn-start flushes use the DB-only override because their messages are + # still needed for the API request; this finalizer runs after that request + # is complete (#48677 / #63766). + _apply_override = getattr(agent, "_apply_persist_user_message_override", None) + if callable(_apply_override): + _apply_override(messages) agent._persist_session(messages, conversation_history) except Exception as _persist_err: _cleanup_errors.append(f"persist_session: {_persist_err}") @@ -304,6 +337,7 @@ def finalize_turn( # truncated partial (the "The" case from #34452). _is_partial_fragment = ( not _is_empty_terminal + and not preserved_verification_fallback and not str(_turn_exit_reason).startswith("text_response") and len(_stripped) <= 24 and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"} @@ -424,6 +458,11 @@ def finalize_turn( "estimated_cost_usd": agent.session_estimated_cost_usd, "cost_status": agent.session_cost_status, "cost_source": agent.session_cost_source, + # Requested service tier (from request_overrides.extra_body), for + # billing audits by callers like `hermes -z --usage-file`. + "service_tier": ( + (getattr(agent, "request_overrides", {}) or {}).get("extra_body") or {} + ).get("service_tier"), "session_id": agent.session_id, } if agent._tool_guardrail_halt_decision is not None: diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index d7b56a9fac42..aa306fa12f8c 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -103,6 +103,54 @@ _UTC_NOW = lambda: datetime.now(timezone.utc) # Official docs snapshot entries. Models whose published pricing and cache # semantics are stable enough to encode exactly. _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = { + # ── OpenAI GPT-5.6 series (Sol/Terra/Luna) ─────────────────────────── + # Announced in limited preview 2026-06-26; GA 2026-07-09 at the same + # rates (Sol $5/$30, Terra $2.50/$15, Luna $1/$6 per 1M in/out). Cache + # writes are billed at 1.25x the uncached input rate; cache reads get the + # standard 90% discount (0.10x input, confirmed: Sol $0.50/M cached). + # Note: "Sol Fast mode" ($12.5/$75, up to 750 tok/s via Cerebras) is a + # separate serving tier, not covered by these entries. The "-pro" + # variants (high-effort modes, GA alongside base tiers) bill at the + # SAME per-token rates and are aliased onto these entries below the + # dict (they cost more per task by consuming more tokens, not by a + # higher rate — verified against OpenRouter's live pricing 2026-07-09). + # Source: https://openai.com/index/previewing-gpt-5-6-sol/ + ( + "openai", + "gpt-5.6-sol", + ): PricingEntry( + input_cost_per_million=Decimal("5.00"), + output_cost_per_million=Decimal("30.00"), + cache_read_cost_per_million=Decimal("0.50"), + cache_write_cost_per_million=Decimal("6.25"), + source="official_docs_snapshot", + source_url="https://openai.com/index/previewing-gpt-5-6-sol/", + pricing_version="openai-gpt-5.6-2026-07", + ), + ( + "openai", + "gpt-5.6-terra", + ): PricingEntry( + input_cost_per_million=Decimal("2.50"), + output_cost_per_million=Decimal("15.00"), + cache_read_cost_per_million=Decimal("0.25"), + cache_write_cost_per_million=Decimal("3.125"), + source="official_docs_snapshot", + source_url="https://openai.com/index/previewing-gpt-5-6-sol/", + pricing_version="openai-gpt-5.6-2026-07", + ), + ( + "openai", + "gpt-5.6-luna", + ): PricingEntry( + input_cost_per_million=Decimal("1.00"), + output_cost_per_million=Decimal("6.00"), + cache_read_cost_per_million=Decimal("0.10"), + cache_write_cost_per_million=Decimal("1.25"), + source="official_docs_snapshot", + source_url="https://openai.com/index/previewing-gpt-5-6-sol/", + pricing_version="openai-gpt-5.6-2026-07", + ), # ── Anthropic Claude 4.8 ───────────────────────────────────────────── # Same $5/$25 base pricing as 4.6/4.7. Fast-mode variant is a separate # model ID with 2x premium (vs the 6x premium on older Opus generations). @@ -563,6 +611,15 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = { ), } +# GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as +# their base tiers (more tokens per task, not a higher rate). Alias them +# onto the base entries so the snapshot stays single-source. +for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"): + _OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[ + ("openai", _base_56) + ] +del _base_56 + def _to_decimal(value: Any) -> Optional[Decimal]: if value is None: @@ -602,7 +659,11 @@ def resolve_billing_route( return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api") if provider_name == "anthropic": return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") - if provider_name == "openai": + # "openai-api" is the picker/registry slug for direct api.openai.com; it + # bills identically to bare "openai", so normalize it here — otherwise the + # ("openai", ) _OFFICIAL_DOCS_PRICING keys are unreachable from the + # openai-api provider path. + if provider_name in {"openai", "openai-api"}: return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") if provider_name in {"minimax", "minimax-cn"}: return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") diff --git a/agent/verification_stop.py b/agent/verification_stop.py index 605d58d3a7de..1f68b1aace55 100644 --- a/agent/verification_stop.py +++ b/agent/verification_stop.py @@ -289,7 +289,7 @@ def build_verify_on_stop_nudge( + "), read any failure, repair the code, and summarize what passed." ) else: - temp_dir = tempfile.gettempdir() + temp_dir = os.path.realpath(tempfile.gettempdir()) command_instruction = ( "No canonical test/lint/build command was detected. Create a focused " f"temporary verification script under `{temp_dir}` using an OS-safe " diff --git a/agent/video_gen_provider.py b/agent/video_gen_provider.py index af8bf9faf785..8630f8f204bb 100644 --- a/agent/video_gen_provider.py +++ b/agent/video_gen_provider.py @@ -244,6 +244,78 @@ def save_bytes_video( return path +_URL_VIDEO_CONTENT_TYPES = { + "video/mp4": "mp4", + "video/webm": "webm", + "video/quicktime": "mov", + "video/x-matroska": "mkv", +} + + +def save_url_video( + url: str, + *, + prefix: str = "video", + timeout: float = 180.0, + max_bytes: int = 200 * 1024 * 1024, +) -> Path: + """Download a video URL and write it under ``$HERMES_HOME/cache/videos/``. + + The video twin of :func:`agent.image_gen_provider.save_url_image`: several + backends (DeepInfra, FAL) return an *ephemeral* delivery URL that expires + before a downstream consumer can fetch it, so we materialise the bytes + locally at tool-completion time. Streams with a size cap. + + Raises on any network / HTTP / oversize error so callers can fall back to + returning the bare URL. + """ + import requests + + response = requests.get(url, timeout=timeout, stream=True) + response.raise_for_status() + + content_type = (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() + extension = _URL_VIDEO_CONTENT_TYPES.get(content_type) + if extension is None: + url_path = url.split("?", 1)[0].lower() + for ext in ("mp4", "webm", "mov", "mkv"): + if url_path.endswith(f".{ext}"): + extension = ext + break + if extension is None: + extension = "mp4" + + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + short = uuid.uuid4().hex[:8] + path = _videos_cache_dir() / f"{prefix}_{ts}_{short}.{extension}" + + bytes_written = 0 + with path.open("wb") as fh: + for chunk in response.iter_content(chunk_size=256 * 1024): + if not chunk: + continue + bytes_written += len(chunk) + if bytes_written > max_bytes: + fh.close() + try: + path.unlink() + except OSError: + pass + raise ValueError( + f"Video at {url} exceeds {max_bytes // (1024 * 1024)}MB cap; refusing to cache." + ) + fh.write(chunk) + + if bytes_written == 0: + try: + path.unlink() + except OSError: + pass + raise ValueError(f"Video at {url} was empty (0 bytes).") + + return path + + def success_response( *, video: str, @@ -297,3 +369,222 @@ def error_response( "aspect_ratio": aspect_ratio, "provider": provider, } + + +# --------------------------------------------------------------------------- +# Reusable OpenAI-compatible backend +# --------------------------------------------------------------------------- + + +class OpenAICompatibleVideoGenProvider(VideoGenProvider): + """Generic text/image-to-video over the OpenAI ``client.videos`` API. + + DeepInfra, OpenAI/Sora, and OpenRouter all expose the same + ``POST /videos`` async-job shape (``create`` → poll → ``download_content``), + so the SDK call lives here once. A concrete backend only needs to declare + its identity and credentials:: + + class FooVideoGenProvider(OpenAICompatibleVideoGenProvider): + name = "foo" + _env_key = "FOO_API_KEY" + _default_base_url = "https://api.foo.com/v1/openai" + def list_models(self): + return [...] # entries with an "id" key; default_model() uses [0] + + ``image_url`` routes to image-to-video; its absence routes to text-to-video. + Provider-specific fields (``image_url``/``negative_prompt``/``seed``) ride + in ``extra_body`` so they pass through the SDK unchanged. + """ + + _env_key: str = "OPENAI_API_KEY" + _default_base_url: str = "https://api.openai.com/v1" + + # Polling cadence for the async video job. The OpenAI SDK's + # ``create_and_poll`` defaults to ~1 poll/second and loops forever on a + # non-terminal status, so a multi-minute job issues hundreds of sequential + # requests and a stuck job pins its tool-executor worker thread with no way + # out. We hand-roll a bounded poll instead: a coarse interval plus a hard + # wall-clock deadline that surfaces a timeout error. + _poll_interval_s: float = 5.0 + _poll_deadline_s: float = 900.0 + + def _api_key(self) -> str: + import os + + return os.environ.get(self._env_key, "").strip() + + def is_available(self) -> bool: + return bool(self._api_key()) + + def _create_and_poll(self, client: Any, call_kwargs: Dict[str, Any]) -> Any: + """Create the video job and poll to completion with a hard deadline. + + Replaces ``client.videos.create_and_poll`` (unbounded 1/s loop) with a + coarse interval and a wall-clock cap. Returns the terminal video object + (any status); raises :class:`TimeoutError` if the deadline passes + first. + """ + import time + + video = client.videos.create(**call_kwargs) + terminal = {"completed", "succeeded", "failed", "error", "cancelled", "canceled"} + deadline = time.monotonic() + self._poll_deadline_s + while getattr(video, "status", None) not in terminal: + if time.monotonic() >= deadline: + raise TimeoutError( + f"video job {getattr(video, 'id', '?')} did not reach a terminal " + f"status within {int(self._poll_deadline_s)}s " + f"(last status={getattr(video, 'status', None)!r})" + ) + time.sleep(self._poll_interval_s) + video = client.videos.retrieve(video.id) + return video + + def _base_url(self) -> str: + import os + + override = os.environ.get(f"{self.name.upper()}_BASE_URL", "").strip() + return override or self._default_base_url + + def generate( + self, + prompt: str, + *, + model: Optional[str] = None, + image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, + duration: Optional[int] = None, + aspect_ratio: str = DEFAULT_ASPECT_RATIO, + resolution: str = DEFAULT_RESOLUTION, + negative_prompt: Optional[str] = None, + audio: Optional[bool] = None, + seed: Optional[int] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + if not prompt or not prompt.strip(): + return error_response( + error="prompt is required", error_type="invalid_request", provider=self.name + ) + if not self._api_key(): + return error_response( + error=f"{self._env_key} is not set", + error_type="missing_credentials", + provider=self.name, + ) + try: + import openai + except ImportError: + return error_response( + error="openai Python package not installed (pip install openai)", + error_type="missing_dependency", + provider=self.name, + ) + + model_id = model or self.default_model() + if not model_id: + return error_response( + error=f"no {self.name} video model available (live catalog empty?)", + error_type="no_model", + provider=self.name, + ) + + # Provider-specific fields the OpenAI ``videos.create`` signature does + # not name natively — pass them through ``extra_body``. + extra_body = { + k: v + for k, v in { + "negative_prompt": negative_prompt, + "aspect_ratio": aspect_ratio, + "image_url": image_url, # presence ⇒ image-to-video + "seed": seed, + }.items() + if v is not None + } + call_kwargs: Dict[str, Any] = {"model": model_id, "prompt": prompt} + if duration: + call_kwargs["seconds"] = str(duration) + if resolution: + call_kwargs["size"] = resolution + if extra_body: + call_kwargs["extra_body"] = extra_body + + client = openai.OpenAI(api_key=self._api_key(), base_url=self._base_url()) + try: + try: + video = self._create_and_poll(client, call_kwargs) + except Exception as exc: # noqa: BLE001 - surface any SDK/API/timeout failure uniformly + logger.debug("%s video generation failed", self.name, exc_info=True) + return error_response( + error=f"{self.name} video generation failed: {exc}", + error_type="api_error", + provider=self.name, + model=model_id, + prompt=prompt, + aspect_ratio=aspect_ratio, + ) + + # Terminal success status differs across backends: DeepInfra reports + # "succeeded", OpenAI/Sora reports "completed". Accept both. + status = getattr(video, "status", None) + if status not in ("completed", "succeeded"): + # ``video.error`` is a structured SDK object (pydantic + # VideoCreateError), not a string — str() it so the response + # dict stays JSON-serializable for the tool layer. + job_error = getattr(video, "error", None) + return error_response( + error=str(job_error) if job_error else f"video job ended with status={status!r}", + error_type="job_failed", + provider=self.name, + model=model_id, + prompt=prompt, + aspect_ratio=aspect_ratio, + ) + + # Resolve the output. Providers expose it either as a delivery URL in + # the job's ``data`` list (DeepInfra, FAL-style) or only via the SDK + # download endpoint (OpenAI/Sora). Download the bytes and save locally + # so the caller gets a durable file — DeepInfra's delivery URLs in + # particular are short-lived. Matches plugins/image_gen/deepinfra. + url = None + for item in getattr(video, "data", None) or []: + candidate = item.get("url") if isinstance(item, dict) else getattr(item, "url", None) + if candidate: + url = candidate + break + + try: + if url: + # Materialise the (often short-lived) delivery URL locally. + video_ref = str(save_url_video(url, prefix=self.name)) + else: + # OpenAI/Sora style: no public URL — pull bytes via the SDK. + raw = client.videos.download_content(video.id).read() + video_ref = str(save_bytes_video(raw, prefix=self.name)) + except Exception as exc: # noqa: BLE001 + if url: + # Best-effort: hand back the URL rather than fail outright. + logger.debug("%s: saving video locally failed (%s); returning URL", self.name, exc) + video_ref = url + else: + return error_response( + error=f"{self.name} video job succeeded but no output could be retrieved: {exc}", + error_type="empty_response", + provider=self.name, + model=model_id, + prompt=prompt, + aspect_ratio=aspect_ratio, + ) + + return success_response( + video=video_ref, + model=model_id, + prompt=prompt, + modality="image" if image_url else "text", + aspect_ratio=aspect_ratio, + duration=duration or 0, + provider=self.name, + ) + finally: + close = getattr(client, "close", None) + if callable(close): + close() diff --git a/agent/video_gen_registry.py b/agent/video_gen_registry.py index ad936e29d42b..c4d28e39ed4b 100644 --- a/agent/video_gen_registry.py +++ b/agent/video_gen_registry.py @@ -11,12 +11,15 @@ Active selection The active provider is chosen by ``video_gen.provider`` in ``config.yaml``. If unset, :func:`get_active_provider` applies fallback logic: -1. If exactly one provider is registered, use it. +1. If exactly one *available* provider is registered, use it. 2. Otherwise return ``None`` (the tool surfaces a helpful error pointing the user at ``hermes tools``). Mirrors ``agent/image_gen_registry.py`` so the two surfaces behave the -same. +same: the unconfigured fallback is filtered by ``is_available()`` so a box +that has credentials for only one backend (e.g. DeepInfra, while the +``fal``/``xai`` plugins also register unconditionally) auto-selects it +instead of returning ``None``. """ from __future__ import annotations @@ -100,13 +103,26 @@ def get_active_provider() -> Optional[VideoGenProvider]: if provider is not None: return provider logger.debug( - "video_gen.provider='%s' configured but not registered; falling back", + "video_gen.provider='%s' configured but not registered; failing closed", configured, ) + return None - # Fallback: single-provider case - if len(snapshot) == 1: - return next(iter(snapshot.values())) + def _is_available_safe(p: VideoGenProvider) -> bool: + """Wrap ``is_available()`` so a buggy provider doesn't kill resolution.""" + try: + return bool(p.is_available()) + except Exception as exc: # noqa: BLE001 + logger.debug("video_gen provider %s.is_available() raised %s", p.name, exc) + return False + + # Fallback: single *available* provider — filter by is_available() so a + # box with credentials for only one backend auto-selects it even when + # other providers (fal/xai) register unconditionally without keys. + # Mirrors agent/image_gen_registry.get_active_provider(). + available = [p for p in snapshot.values() if _is_available_safe(p)] + if len(available) == 1: + return available[0] return None diff --git a/apps/bootstrap-installer/eslint.config.mjs b/apps/bootstrap-installer/eslint.config.mjs new file mode 100644 index 000000000000..ea43653a9bf7 --- /dev/null +++ b/apps/bootstrap-installer/eslint.config.mjs @@ -0,0 +1,5 @@ +import shared from '../../eslint.config.shared.mjs' + +export default [ + ...shared +] diff --git a/apps/bootstrap-installer/package.json b/apps/bootstrap-installer/package.json index 4638a8c905ea..ba231432350b 100644 --- a/apps/bootstrap-installer/package.json +++ b/apps/bootstrap-installer/package.json @@ -12,7 +12,11 @@ "tauri:dev": "tauri dev", "tauri:build": "tauri build", "tauri:build:debug": "tauri build --debug", - "typecheck": "tsc -p . --noEmit" + "typecheck": "tsc -p . --noEmit", + "check": "npm run typecheck", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "fix": "npm run lint:fix" }, "dependencies": { "@nous-research/ui": "0.16.0", @@ -37,11 +41,19 @@ "tw-shimmer": "^0.4.11" }, "devDependencies": { + "@eslint/js": "^9.39.4", "@tauri-apps/cli": "^2.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", + "eslint": "^9.39.4", + "eslint-plugin-perfectionist": "^5.9.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^17.4.0", "typescript": "^6.0.3", + "typescript-eslint": "^8.56.1", "vite": "^8.0.16" } } diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs index a8fcd656b8ab..1d70ec59a611 100644 --- a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs @@ -1,6 +1,6 @@ //! Bootstrap orchestration. //! -//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`. +//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.ts`. //! Drives install.ps1 / install.sh stage-by-stage, emits progress events //! over the Tauri `bootstrap` channel, writes a forensic log to //! HERMES_HOME/logs/bootstrap-.log. diff --git a/apps/bootstrap-installer/src-tauri/src/events.rs b/apps/bootstrap-installer/src-tauri/src/events.rs index e00105013bef..afadbf8e868a 100644 --- a/apps/bootstrap-installer/src-tauri/src/events.rs +++ b/apps/bootstrap-installer/src-tauri/src/events.rs @@ -1,6 +1,6 @@ //! Event types streamed from Rust → React. //! -//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape +//! These mirror `apps/desktop/electron/bootstrap-runner.ts`'s event shape //! 1:1 so the React installer code can be roughly identical to the Electron //! install-overlay we'll replace. //! diff --git a/apps/bootstrap-installer/src-tauri/src/install_script.rs b/apps/bootstrap-installer/src-tauri/src/install_script.rs index 217ee9fef5a7..67a114408f89 100644 --- a/apps/bootstrap-installer/src-tauri/src/install_script.rs +++ b/apps/bootstrap-installer/src-tauri/src/install_script.rs @@ -8,7 +8,7 @@ //! 3. Network: download from GitHub raw at a pinned commit or branch. //! Commit pins are immutable; branch pins are HEAD-tracking. //! -//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`, +//! Mirrors `apps/desktop/electron/bootstrap-runner.ts`'s `resolveInstallScript`, //! but the dev-checkout resolution is driven by an env var rather than the //! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant //! to live OUTSIDE any repo checkout. @@ -64,7 +64,7 @@ impl ScriptKind { } /// Validates a string looks like a git SHA (7+ hex chars). Mirrors -/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs. +/// `STAMP_COMMIT_RE` from bootstrap-runner.ts. fn is_valid_commit(s: &str) -> bool { let len = s.len(); (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit()) diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs index 99ad16f6b883..7c64c91cf6ef 100644 --- a/apps/bootstrap-installer/src-tauri/src/paths.rs +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -150,7 +150,7 @@ fn repair_macos_installer_helper(path: &Path) { fn repair_macos_installer_helper(_path: &Path) {} /// Where install.ps1 writes the bootstrap-complete marker (existence-only file -/// the Electron app also checks). Per main.cjs: +/// the Electron app also checks). Per main.ts: /// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete') /// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so /// this is a probe helper, not a definitive path. diff --git a/apps/bootstrap-installer/src-tauri/src/powershell.rs b/apps/bootstrap-installer/src-tauri/src/powershell.rs index f37a3c68b36d..04694c113b52 100644 --- a/apps/bootstrap-installer/src-tauri/src/powershell.rs +++ b/apps/bootstrap-installer/src-tauri/src/powershell.rs @@ -1,6 +1,6 @@ //! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh. //! -//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same +//! Port of `spawnPowerShell` from bootstrap-runner.ts, with the same //! line-buffered stdout/stderr streaming + cancellation semantics. //! //! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File ' @@ -39,9 +40,11 @@ test('dashboardIndexUrl preserves dashboard path prefixes', () => { test('resolveServedDashboardToken uses the served token and logs when it differs', async () => { const logs = [] + const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { fetchText: async url => { assert.equal(url, 'http://127.0.0.1:9120/') + return '' }, rememberLog: line => logs.push(line) @@ -100,8 +103,9 @@ test('isForeignBackendToken only flags a mismatched token from a dead child', () [{ servedToken: null, spawnToken: 'mine', childAlive: false }, false], [{ servedToken: '', spawnToken: 'mine', childAlive: false }, false] ] + for (const [input, expected] of cases) { - assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input)) + assert.equal(isForeignBackendToken(input as any), expected, JSON.stringify(input)) } }) @@ -128,6 +132,7 @@ test('adoptServedDashboardToken refuses a foreign token when our child is dead', test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => { const logs = [] + const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { childAlive: () => true, fetchText: async () => { diff --git a/apps/desktop/electron/dashboard-token.cjs b/apps/desktop/electron/dashboard-token.ts similarity index 93% rename from apps/desktop/electron/dashboard-token.cjs rename to apps/desktop/electron/dashboard-token.ts index 1a9ca50ad9c2..42f214853434 100644 --- a/apps/desktop/electron/dashboard-token.cjs +++ b/apps/desktop/electron/dashboard-token.ts @@ -9,29 +9,39 @@ const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000 -async function fetchPublicText(url, options = {}) { +async function fetchPublicText(url, options: any = {}) { const { protocol } = new URL(url) + if (protocol !== 'http:' && protocol !== 'https:') { throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`) } const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => { if (error.name === 'TimeoutError') { throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`) } + throw error }) + const text = await res.text() - if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`) + if (!res.ok) { + throw new Error(`${res.status}: ${text || res.statusText}`) + } return text } function extractInjectedDashboardToken(html) { const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || '')) - if (!match) return null + + if (!match) { + return null + } + try { return JSON.parse(match[1]) } catch { @@ -43,11 +53,13 @@ function dashboardIndexUrl(baseUrl) { return `${String(baseUrl || '').replace(/\/+$/, '')}/` } -async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) { +async function resolveServedDashboardToken(baseUrl, fallbackToken, options: any = {}) { const fetchText = options.fetchText || fetchPublicText + const html = await fetchText(dashboardIndexUrl(baseUrl), { timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS }) + const servedToken = extractInjectedDashboardToken(html) if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') { @@ -76,6 +88,7 @@ function isForeignBackendToken({ servedToken, spawnToken, childAlive }) { async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) { const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => { options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`) + return spawnToken }) @@ -88,10 +101,10 @@ async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, labe return servedToken } -module.exports = { - DEFAULT_TOKEN_FETCH_TIMEOUT_MS, +export { adoptServedDashboardToken, dashboardIndexUrl, + DEFAULT_TOKEN_FETCH_TIMEOUT_MS, extractInjectedDashboardToken, fetchPublicText, isForeignBackendToken, diff --git a/apps/desktop/electron/desktop-electron-pin.test.ts b/apps/desktop/electron/desktop-electron-pin.test.ts new file mode 100644 index 000000000000..d94ab570ce3c --- /dev/null +++ b/apps/desktop/electron/desktop-electron-pin.test.ts @@ -0,0 +1,98 @@ +/** + * Regression: the desktop Electron dependency must be an exact, consistent pin. + * + * The Windows desktop install failed at "Building desktop app" because Electron + * changed its install mechanism mid patch-series: + * + * electron 40.9.3 .. 40.10.2 -> @electron/get@^2 + extract-zip@^2 (pure JS) + * electron 40.10.3 / 40.10.4 -> @electron/get@^5 + + * @electron-internal/extract-zip@^1 (native napi) + * + * ``apps/desktop/package.json`` declared ``electronVersion: 40.9.3`` (the tested, + * JS-extract build) but pinned the dependency loosely as ``electron: ^40.9.3``. + * ``npm ci`` then resolved 40.10.3/40.10.4 — the new *native* extract-zip whose + * win32-x64 binding fails to ``dlopen`` on some Windows hosts + * (``ERR_DLOPEN_FAILED loading index.win32-x64-msvc.node``). + * + * These tests lock the contract that prevents that drift, without hard-coding the + * specific version (which is allowed to move): + * + * 1. the Electron dependency is an *exact* version (Electron Builder needs the + * installed binary to match ``electronVersion`` / ``electronDist``), and + * 2. the dependency, ``build.electronVersion``, and the resolved lockfile entry + * all agree — so ``npm ci`` installs exactly what the build packages. + */ + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' + +import { test } from 'vitest' + +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') +const DESKTOP_PKG = path.join(REPO_ROOT, 'apps', 'desktop', 'package.json') +const ROOT_LOCK = path.join(REPO_ROOT, 'package-lock.json') + +// An exact semver: digits.digits.digits with an optional prerelease/build tag, +// but NO range operators (^ ~ > < = * x || spaces || -range). +const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/ + +function desktopPkg(): Record { + assert.ok(fs.existsSync(DESKTOP_PKG), `missing ${DESKTOP_PKG}`) + return JSON.parse(fs.readFileSync(DESKTOP_PKG, 'utf-8')) +} + +function electronSpec(pkg: Record): string { + for (const section of ['dependencies', 'devDependencies'] as const) { + const deps = (pkg[section] ?? {}) as Record + const spec = deps['electron'] + if (spec) return spec + } + assert.fail('electron is not listed in apps/desktop dependencies') +} + +test('electron dependency is exactly pinned', () => { + const spec = electronSpec(desktopPkg()) + assert.match( + spec, + EXACT_SEMVER, + `electron must be pinned to an exact version, got "${spec}". ` + + 'A range (^/~) lets npm ci resolve a newer Electron whose postinstall ' + + 'may differ from the one the build was validated against.' + ) +}) + +test('electron dependency matches build.electronVersion', () => { + const pkg = desktopPkg() + const spec = electronSpec(pkg) + const build = (pkg.build ?? {}) as Record + const builderVersion = build.electronVersion as string | undefined + assert.ok(builderVersion, 'build.electronVersion is missing') + assert.equal( + spec, + builderVersion, + `electron dependency ("${spec}") must equal build.electronVersion ` + + `("${builderVersion}"); otherwise electron-builder packages a different ` + + 'version than npm installs into electronDist.' + ) +}) + +test('lockfile resolves the pinned electron', () => { + if (!fs.existsSync(ROOT_LOCK)) return // skip if lockfile not present + const spec = electronSpec(desktopPkg()) + const lock = JSON.parse(fs.readFileSync(ROOT_LOCK, 'utf-8')) + const packages = (lock.packages ?? {}) as Record + const resolved = Object.entries(packages) + .filter(([key]) => key.endsWith('node_modules/electron')) + .map(([, meta]) => meta.version) + .filter((v): v is string => !!v) + assert.ok(resolved.length > 0, 'no electron entry found in package-lock.json') + for (const v of resolved) { + assert.equal( + v, + spec, + `package-lock.json resolves electron to ${v}, but the pin is "${spec}"; ` + + 'run `npm install --package-lock-only` so `npm ci` stays consistent.' + ) + } +}) diff --git a/apps/desktop/electron/desktop-uninstall.test.cjs b/apps/desktop/electron/desktop-uninstall.test.ts similarity index 97% rename from apps/desktop/electron/desktop-uninstall.test.cjs rename to apps/desktop/electron/desktop-uninstall.test.ts index 15a864b7c4f4..6d296ee1db91 100644 --- a/apps/desktop/electron/desktop-uninstall.test.cjs +++ b/apps/desktop/electron/desktop-uninstall.test.ts @@ -1,7 +1,7 @@ /** - * Tests for electron/desktop-uninstall.cjs. + * Tests for electron/desktop-uninstall.ts. * - * Run with: node --test electron/desktop-uninstall.test.cjs + * Run with: node --test electron/desktop-uninstall.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * These are the pure helpers behind the desktop Chat GUI uninstaller: the @@ -9,19 +9,20 @@ * cleanup-script builders (POSIX + Windows). */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' -const { - UNINSTALL_MODES, +import { test } from 'vitest' + +import { buildPosixCleanupScript, buildWindowsCleanupScript, modeRemovesAgent, modeRemovesUserData, resolveRemovableAppPath, shouldRemoveAppBundle, + UNINSTALL_MODES, uninstallArgsForMode -} = require('./desktop-uninstall.cjs') +} from './desktop-uninstall' // --- uninstallArgsForMode --- @@ -132,6 +133,7 @@ test('buildPosixCleanupScript waits for the PID, runs the uninstall module, remo appPath: '/opt/hermes/linux-unpacked', hermesHome: '/home/x/.hermes' }) + assert.match(script, /^#!\/bin\/bash/) assert.match(script, /pid=4321/) assert.match(script, /kill -0 "\$pid"/) @@ -152,6 +154,7 @@ test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/fu appPath: null, hermesHome: '/home/x/.hermes' }) + // System python + source on PYTHONPATH so import hermes_cli works while the // venv is torn down. assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/) @@ -168,6 +171,7 @@ test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', ( appPath: null, hermesHome: '/h' }) + assert.doesNotMatch(script, /export PYTHONPATH/) }) @@ -181,6 +185,7 @@ test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => { appPath: null, hermesHome: '/h' }) + assert.doesNotMatch(script, /rm -rf '\//) // Still runs the uninstall. assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/) @@ -196,6 +201,7 @@ test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () = appPath: null, hermesHome: '/h' }) + // The apostrophe is closed-escaped-reopened so the shell sees the literal. assert.match(script, /'\/home\/o'\\''brien\/python'/) }) @@ -212,6 +218,7 @@ test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir b appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes', hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes' }) + assert.match(script, /@echo off/) assert.match(script, /set "PID=9988"/) // PYTHONPATH set so a system python can import hermes_cli from source. @@ -238,6 +245,7 @@ test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, n appPath: null, hermesHome: 'C:\\h' }) + assert.doesNotMatch(script, /rmdir/) assert.doesNotMatch(script, /set "PYTHONPATH=/) }) diff --git a/apps/desktop/electron/desktop-uninstall.cjs b/apps/desktop/electron/desktop-uninstall.ts similarity index 93% rename from apps/desktop/electron/desktop-uninstall.cjs rename to apps/desktop/electron/desktop-uninstall.ts index 01b756acd1ed..3d6448653823 100644 --- a/apps/desktop/electron/desktop-uninstall.cjs +++ b/apps/desktop/electron/desktop-uninstall.ts @@ -1,14 +1,14 @@ /** - * desktop-uninstall.cjs + * desktop-uninstall.ts * * Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map * the three user-facing uninstall modes to the `hermes uninstall` CLI flags, * resolve the running app bundle/exe so a detached cleanup script can remove * it after the app quits, and build that cleanup script for each OS. * - * Kept standalone (no `require('electron')`) so it can be unit-tested with - * `node --test` — same pattern as connection-config.cjs / backend-probes.cjs. - * main.cjs requires these and wires them into the electron-coupled IPC layer. + * Kept standalone (no ` import 'electron'`) so it can be unit-tested with + * `node --test` — same pattern as connection-config.ts / backend-probes.ts. + * main.ts requires these and wires them into the electron-coupled IPC layer. * * The three modes mirror the CLI's options exactly: * - 'gui' → remove ONLY the Chat GUI, keep the agent + all user data. @@ -23,10 +23,10 @@ * app bundle (locked on macOS/Windows while the process is alive). So we hand * the work to a detached child that waits for this app's PID to exit, runs the * Python uninstall, then removes the app bundle — then the app quits. Same - * shape as the self-update swap-and-relaunch flow already in main.cjs. + * shape as the self-update swap-and-relaunch flow already in main.ts. */ -const path = require('node:path') +import path from 'node:path' const UNINSTALL_MODES = ['gui', 'lite', 'full'] @@ -41,6 +41,7 @@ function uninstallArgsForMode(mode) { if (!UNINSTALL_MODES.includes(mode)) { throw new Error(`Unknown uninstall mode: ${mode}`) } + return ['-m', 'hermes_cli.uninstall', '--mode', mode] } @@ -65,9 +66,12 @@ function modeRemovesUserData(mode) { * Returns null when we can't confidently identify a removable bundle (e.g. * running from a dev checkout, or a system-package install we must not rmtree). */ -function resolveRemovableAppPath(execPath, platform, env = {}) { +function resolveRemovableAppPath(execPath, platform, env: any = {}) { const exe = String(execPath || '') - if (!exe) return null + + if (!exe) { + return null + } // Use the path flavor that matches the TARGET platform, not the host running // this code — so the Windows branch parses backslash paths correctly even @@ -79,22 +83,37 @@ function resolveRemovableAppPath(execPath, platform, env = {}) { const macOsDir = p.dirname(exe) // …/Contents/MacOS const contents = p.dirname(macOsDir) // …/Contents const appBundle = p.dirname(contents) // …/Hermes.app - if (appBundle.endsWith('.app')) return appBundle + + if (appBundle.endsWith('.app')) { + return appBundle + } + return null } if (platform === 'win32') { // NSIS per-user installs Hermes.exe directly in the install dir. const dir = p.dirname(exe) - if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir + + if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) { + return dir + } + return null } // Linux: an AppImage exposes its own path via the APPIMAGE env var. - if (env.APPIMAGE) return env.APPIMAGE + if (env.APPIMAGE) { + return env.APPIMAGE + } + // Unpacked electron-builder tree: …/linux-unpacked/hermes const dir = p.dirname(exe) - if (/-unpacked$/.test(dir)) return dir + + if (/-unpacked$/.test(dir)) { + return dir + } + return null } @@ -121,6 +140,7 @@ function shouldRemoveAppBundle(isPackaged, appPath) { */ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) { const q = s => `'${String(s).replace(/'/g, `'\\''`)}'` + const lines = [ '#!/bin/bash', 'set -u', @@ -135,16 +155,21 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, 'fi', `export HERMES_HOME=${q(hermesHome)}` ] + if (pythonPath) { lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`) } + lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`) + if (appPath) { lines.push(`rm -rf ${q(appPath)} || true`) } + // Self-delete the script. lines.push('rm -f "$0" 2>/dev/null || true') lines.push('') + return lines.join('\n') } @@ -180,15 +205,18 @@ function buildWindowsCleanupScript({ // under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be // a problem, but Hermes install paths don't use them. const q = s => `"${String(s).replace(/"/g, '')}"` + const lines = [ '@echo off', 'setlocal enableextensions', `set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`, `set "PID=${pid}"` ] + if (pythonPath) { lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`) } + lines.push( 'set /a waited=0', ':waitloop', @@ -206,6 +234,7 @@ function buildWindowsCleanupScript({ `cd /d ${q(agentRoot)}`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}` ) + if (appPath) { lines.push( 'set /a tries=0', @@ -220,18 +249,20 @@ function buildWindowsCleanupScript({ ':rmdone' ) } + lines.push('del "%~f0"') lines.push('') + return lines.join('\r\n') } -module.exports = { - UNINSTALL_MODES, +export { buildPosixCleanupScript, buildWindowsCleanupScript, modeRemovesAgent, modeRemovesUserData, resolveRemovableAppPath, shouldRemoveAppBundle, + UNINSTALL_MODES, uninstallArgsForMode } diff --git a/apps/desktop/electron/embed-referer.cjs b/apps/desktop/electron/embed-referer.ts similarity index 92% rename from apps/desktop/electron/embed-referer.cjs rename to apps/desktop/electron/embed-referer.ts index 2825eda40e70..834fca0cfb24 100644 --- a/apps/desktop/electron/embed-referer.cjs +++ b/apps/desktop/electron/embed-referer.ts @@ -1,9 +1,8 @@ -'use strict' - -const { session } = require('electron') +import { session } from 'electron' const EMBED_SESSION_PARTITION = 'persist:hermes-embed' const EMBED_REFERER = 'https://www.youtube.com/' + const YOUTUBE_REFERER_HOST_RE = /(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i @@ -23,6 +22,7 @@ function installEmbedRefererForSession(embedSession) { if (!YOUTUBE_REFERER_HOST_RE.test(host)) { callback({ requestHeaders: details.requestHeaders }) + return } @@ -45,4 +45,4 @@ function installEmbedReferer() { } } -module.exports = { installEmbedReferer } +export { installEmbedReferer } diff --git a/apps/desktop/electron/fs-read-dir.test.cjs b/apps/desktop/electron/fs-read-dir.test.ts similarity index 97% rename from apps/desktop/electron/fs-read-dir.test.cjs rename to apps/desktop/electron/fs-read-dir.test.ts index 558ec95b5396..c77e60434ec1 100644 --- a/apps/desktop/electron/fs-read-dir.test.cjs +++ b/apps/desktop/electron/fs-read-dir.test.ts @@ -1,19 +1,18 @@ -'use strict' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') -const { pathToFileURL } = require('node:url') +import { test } from 'vitest' -const { readDirForIpc } = require('./fs-read-dir.cjs') +import { readDirForIpc } from './fs-read-dir' function mkTmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-')) } -function fakeDirent(name, flags = {}) { +function fakeDirent(name, flags: any = {}) { return { name, isDirectory: () => Boolean(flags.directory), @@ -109,10 +108,12 @@ test('readDirForIpc accepts file URLs for directories', async () => { test('readDirForIpc returns invalid-path for blank or non-string input', async () => { let readdirCalls = 0 + const fsImpl = { promises: { readdir: async () => { readdirCalls += 1 + return [] } } @@ -126,10 +127,12 @@ test('readDirForIpc returns invalid-path for blank or non-string input', async ( test('readDirForIpc rejects Windows device paths before readdir', async () => { let readdirCalls = 0 + const fsImpl = { promises: { readdir: async () => { readdirCalls += 1 + return [] } } @@ -224,6 +227,7 @@ test('readDirForIpc allows expanding symlink or junction directories outside the fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok') const linkPath = path.join(root, 'outside-link') + try { fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir') } catch (error) { @@ -252,6 +256,7 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th const input = path.join('virtual-root') const resolved = path.resolve(input) const statCalls = [] + const fsImpl = { promises: { readdir: async () => [ @@ -266,9 +271,11 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th } statCalls.push(fullPath) + if (fullPath.endsWith(`${path.sep}linked-dir`)) { return { isDirectory: () => true } } + throw Object.assign(new Error('gone'), { code: 'ENOENT' }) } } @@ -301,12 +308,15 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out let peak = 0 let releaseStats let markFirstStatStarted + const statsReleased = new Promise(resolve => { releaseStats = resolve }) + const firstStatStarted = new Promise(resolve => { markFirstStatStarted = resolve }) + const fsImpl = { promises: { readdir: async () => [ @@ -326,6 +336,7 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out active -= 1 const name = path.basename(fullPath) + if (name === failedName) { throw Object.assign(new Error('gone'), { code: 'ENOENT' }) } diff --git a/apps/desktop/electron/fs-read-dir.cjs b/apps/desktop/electron/fs-read-dir.ts similarity index 79% rename from apps/desktop/electron/fs-read-dir.cjs rename to apps/desktop/electron/fs-read-dir.ts index 1a2a00313b56..fe8f58b0bf28 100644 --- a/apps/desktop/electron/fs-read-dir.cjs +++ b/apps/desktop/electron/fs-read-dir.ts @@ -1,8 +1,8 @@ -'use strict' +import fs from 'node:fs' +import path from 'node:path' -const fs = require('node:fs') -const path = require('node:path') -const { resolveDirectoryForIpc } = require('./hardening.cjs') +import { resolveDirectoryForIpc } from './hardening' +import { resolveLocalReadPath } from './wsl-path-bridge' const FS_READDIR_STAT_CONCURRENCY = 16 @@ -37,7 +37,9 @@ function direntIsSymbolicLink(dirent) { } function shouldStatDirent(dirent) { - if (direntIsDirectory(dirent)) return false + if (direntIsDirectory(dirent)) { + return false + } return direntIsSymbolicLink(dirent) || !direntIsFile(dirent) } @@ -70,18 +72,22 @@ async function mapWithStatConcurrency(items, mapper) { } const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length) - const workers = Array.from({ length: workerCount }, () => runWorker()) + const workers = Array.from({ length: workerCount } as any, () => runWorker()) await Promise.all(workers) return results } -async function readDirForIpc(dirPath, options = {}) { +async function readDirForIpc(dirPath, options: any = {}) { const fsImpl = options.fs || fs let resolved + // On a Windows host with a WSL backend, a WSL/POSIX cwd (`/home/...`, + // `/mnt/c/...`) isn't readable as-is; bridge it to a UNC/drive form first. + const readPath = resolveLocalReadPath(String(dirPath ?? '')) + try { - ;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, { + ;({ resolvedPath: resolved } = await resolveDirectoryForIpc(readPath, { fs: fsImpl, purpose: 'Directory read' })) @@ -102,6 +108,4 @@ async function readDirForIpc(dirPath, options = {}) { } } -module.exports = { - readDirForIpc -} +export { readDirForIpc } diff --git a/apps/desktop/electron/gateway-ws-probe.test.cjs b/apps/desktop/electron/gateway-ws-probe.test.ts similarity index 89% rename from apps/desktop/electron/gateway-ws-probe.test.cjs rename to apps/desktop/electron/gateway-ws-probe.test.ts index 810494fdc47b..222d3c699c16 100644 --- a/apps/desktop/electron/gateway-ws-probe.test.cjs +++ b/apps/desktop/electron/gateway-ws-probe.test.ts @@ -1,7 +1,7 @@ /** - * Tests for electron/gateway-ws-probe.cjs. + * Tests for electron/gateway-ws-probe.ts. * - * Run with: node --test electron/gateway-ws-probe.test.cjs + * Run with: node --test electron/gateway-ws-probe.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * The probe drives a real WebSocket handshake for the "Test remote" button. @@ -9,16 +9,21 @@ * outcome (open, frame, error, early close, never-opens) without a network. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' -const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') +import { test } from 'vitest' + +import { probeGatewayWebSocket } from './gateway-ws-probe' // Minimal WebSocket double: records listeners synchronously (the probe attaches // them in its executor) and exposes emit() so the test can replay events. -function makeFakeWs() { +function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } { const instances = [] + class FakeWs { + url: string + closed = false + listeners: Record = {} constructor(url) { this.url = url this.listeners = {} @@ -32,9 +37,12 @@ function makeFakeWs() { this.closed = true } emit(type, event) { - for (const fn of this.listeners[type] || []) fn(event) + for (const fn of this.listeners[type] || []) { + fn(event) + } } } + return { FakeWs, instances } } @@ -51,11 +59,13 @@ test('probe resolves ok when the socket opens and stays open', async () => { test('probe resolves ok immediately when a frame arrives', async () => { const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, connectTimeoutMs: 1_000, readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer }) + instances[0].emit('open') instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' }) const result = await promise @@ -95,11 +105,13 @@ test('probe fails when the gateway accepts then immediately closes (auth rejecte test('probe times out when the socket never opens', async () => { const { FakeWs } = makeFakeWs() + const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, connectTimeoutMs: 20, readyGraceMs: 10 }) + assert.equal(result.ok, false) assert.match(result.reason, /Timed out/) }) diff --git a/apps/desktop/electron/gateway-ws-probe.cjs b/apps/desktop/electron/gateway-ws-probe.ts similarity index 86% rename from apps/desktop/electron/gateway-ws-probe.cjs rename to apps/desktop/electron/gateway-ws-probe.ts index 6ed1280be982..152e20b4d322 100644 --- a/apps/desktop/electron/gateway-ws-probe.cjs +++ b/apps/desktop/electron/gateway-ws-probe.ts @@ -36,13 +36,16 @@ const DEFAULT_READY_GRACE_MS = 750 * Attempt a live WebSocket connection and classify the outcome. * * @param {string} wsUrl - Fully-formed ws(s):// URL including the credential. - * @param {object} [options] - * @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor. - * @param {number} [options.connectTimeoutMs] - * @param {number} [options.readyGraceMs] * @returns {Promise<{ ok: boolean, reason?: string }>} */ -function probeGatewayWebSocket(wsUrl, options = {}) { +function probeGatewayWebSocket( + wsUrl: string, + options: { + WebSocketImpl?: any + connectTimeoutMs?: number + readyGraceMs?: number + } = {} +) { const WebSocketImpl = options.WebSocketImpl const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS @@ -54,7 +57,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) { }) } - return new Promise(resolve => { + return new Promise(resolve => { let settled = false let opened = false let connectTimer = null @@ -66,6 +69,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) { clearTimeout(connectTimer) connectTimer = null } + if (graceTimer !== null) { clearTimeout(graceTimer) graceTimer = null @@ -73,14 +77,19 @@ function probeGatewayWebSocket(wsUrl, options = {}) { } const finish = result => { - if (settled) return + if (settled) { + return + } + settled = true clearTimers() + try { socket?.close?.() } catch { // ignore — best effort teardown } + resolve(result) } @@ -91,11 +100,15 @@ function probeGatewayWebSocket(wsUrl, options = {}) { ok: false, reason: error instanceof Error ? error.message : String(error) }) + return } const onOpen = () => { - if (settled) return + if (settled) { + return + } + opened = true // Upgrade accepted. Give the server a brief window to reject the // credential post-handshake (early close) before declaring success. @@ -118,7 +131,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) { } const onClose = event => { - if (settled) return + if (settled) { + return + } + if (opened) { // Opened, then closed inside the grace window: the upgrade was accepted // but the session was refused (e.g. ws-ticket/token rejected, or a @@ -127,8 +143,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) { ok: false, reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).') }) + return } + finish({ ok: false, reason: closeReason(event, 'The gateway closed the WebSocket before it opened.') @@ -154,8 +172,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) { function addListener(socket, type, handler) { if (typeof socket.addEventListener === 'function') { socket.addEventListener(type, handler) + return } + // Node's global WebSocket implements addEventListener; this fallback keeps the // helper usable with the `ws` package's EventEmitter shape too. if (typeof socket.on === 'function') { @@ -164,25 +184,44 @@ function addListener(socket, type, handler) { } function extractErrorReason(event) { - if (!event) return '' - if (event instanceof Error) return event.message + if (!event) { + return '' + } + + if (event instanceof Error) { + return event.message + } + const err = event.error || event.message - if (err instanceof Error) return err.message - if (typeof err === 'string') return err + + if (err instanceof Error) { + return err.message + } + + if (typeof err === 'string') { + return err + } + return '' } function closeReason(event, fallback) { const code = event && typeof event.code === 'number' ? event.code : null const reason = event && typeof event.reason === 'string' ? event.reason.trim() : '' - if (code && reason) return `${fallback} (code ${code}: ${reason})` - if (code) return `${fallback} (code ${code})` - if (reason) return `${fallback} (${reason})` + + if (code && reason) { + return `${fallback} (code ${code}: ${reason})` + } + + if (code) { + return `${fallback} (code ${code})` + } + + if (reason) { + return `${fallback} (${reason})` + } + return fallback } -module.exports = { - DEFAULT_CONNECT_TIMEOUT_MS, - DEFAULT_READY_GRACE_MS, - probeGatewayWebSocket -} +export { DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READY_GRACE_MS, probeGatewayWebSocket } diff --git a/apps/desktop/electron/git-repo-scan.cjs b/apps/desktop/electron/git-repo-scan.ts similarity index 93% rename from apps/desktop/electron/git-repo-scan.cjs rename to apps/desktop/electron/git-repo-scan.ts index f7617b76b708..36ac189e66e4 100644 --- a/apps/desktop/electron/git-repo-scan.cjs +++ b/apps/desktop/electron/git-repo-scan.ts @@ -1,14 +1,12 @@ -'use strict' - // Repo-first discovery: walk bounded roots for git repos using only Node's `fs` // — no native addon, so it just works for anyone who pulls main (no // electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git` // (don't descend into a repo), cap depth, and skip heavy non-repo trees so the // first scan stays fast. Results are cached by the backend after the first run. -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' const fsp = fs.promises @@ -36,14 +34,14 @@ async function mapLimit(items, limit, fn) { } } - await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker)) } /** * Scan `roots` (default: the home dir) for git repositories. Returns deduped * `{ root, label }` entries. `options.maxDepth` caps recursion (default 3). */ -async function scanGitRepos(roots, options = {}) { +async function scanGitRepos(roots, options: any = {}) { const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()] const found = new Map() @@ -54,6 +52,7 @@ async function scanGitRepos(roots, options = {}) { } let entries + try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { @@ -73,6 +72,7 @@ async function scanGitRepos(roots, options = {}) { } const subdirs = [] + for (const entry of entries) { // Real directories only (skip symlinks to avoid loops), no hidden dirs, no // known heavy trees. @@ -93,4 +93,4 @@ async function scanGitRepos(roots, options = {}) { return [...found.entries()].map(([root, label]) => ({ label, root })) } -module.exports = { scanGitRepos } +export { scanGitRepos } diff --git a/apps/desktop/electron/git-review-ops.test.cjs b/apps/desktop/electron/git-review-ops.test.ts similarity index 78% rename from apps/desktop/electron/git-review-ops.test.cjs rename to apps/desktop/electron/git-review-ops.test.ts index fdddd13df78b..a06fcdace39f 100644 --- a/apps/desktop/electron/git-review-ops.test.cjs +++ b/apps/desktop/electron/git-review-ops.test.ts @@ -1,9 +1,8 @@ -'use strict' +import assert from 'node:assert/strict' -const assert = require('node:assert/strict') -const test = require('node:test') +import { test } from 'vitest' -const { resolveRenamePath } = require('./git-review-ops.cjs') +import { resolveRenamePath } from './git-review-ops' test('resolveRenamePath: plain path is unchanged', () => { assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts') diff --git a/apps/desktop/electron/git-review-ops.cjs b/apps/desktop/electron/git-review-ops.ts similarity index 93% rename from apps/desktop/electron/git-review-ops.cjs rename to apps/desktop/electron/git-review-ops.ts index a7df19880ebf..1baf26d2fca3 100644 --- a/apps/desktop/electron/git-review-ops.cjs +++ b/apps/desktop/electron/git-review-ops.ts @@ -1,37 +1,16 @@ -'use strict' - // Git ops backing the coding rail + Codex-style review pane. Built on `simple-git` // (a maintained wrapper around the system git binary — same git the rest of the // app shells to, no native build) so we read structured status()/diffSummary() // results instead of hand-parsing porcelain. Reads degrade to null/empty on a // non-repo / remote backend; mutations reject so the renderer can toast. -const { execFile } = require('node:child_process') -const fs = require('node:fs/promises') -const path = require('node:path') +import { execFile } from 'node:child_process' +import fs from 'node:fs/promises' +import path from 'node:path' -// `simple-git` is a pure-JS runtime dep that workspace dedup hoists into the -// repo-root node_modules. Packaged builds set `files:` in package.json, which -// excludes node_modules from the asar, so the normal require() fails at launch -// (issue #52735: "Cannot find module 'simple-git'"). We ship the dep's -// closure under resources/native-deps/vendor/node_modules/ via extraResources -// + scripts/stage-native-deps.cjs, and resolve from there when the hoisted -// require() isn't reachable. The `vendor/` nesting matters: electron-builder -// drops a node_modules dir at the root of an extraResources copy but keeps a -// nested one. Dev mode never hits the fallback -- Node's normal lookup finds -// the hoisted copy. -let simpleGit -try { - simpleGit = require('simple-git') -} catch { - const resourcesPath = process.resourcesPath - if (!resourcesPath) { - throw new Error("git-review IPC: 'simple-git' not found and no resourcesPath to fall back to") - } - simpleGit = require(path.join(resourcesPath, 'native-deps', 'vendor', 'node_modules', 'simple-git')) -} +import simpleGit from 'simple-git' -const { resolveRequestedPathForIpc } = require('./hardening.cjs') +import { resolveRequestedPathForIpc } from './hardening' const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000 const COMMIT_CONTEXT_UNTRACKED_MAX = 80 @@ -52,7 +31,7 @@ function ghEnv(ghBin) { // Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on // availability/auth without a throw. gh missing/unauthed → ok:false. -function runGh(args, cwd, ghBin) { +function runGh(args, cwd, ghBin): Promise<{ ok: boolean; stdout: string }> { return new Promise(resolve => { execFile( ghBin || 'gh', @@ -260,10 +239,11 @@ async function reviewList(repoPath, scope, baseRef, gitBin) { const range = scope === 'branch' ? `${base}...HEAD` : base const summary = await git.diffSummary([range]) + const files = summary.files.map(file => ({ path: resolveRenamePath(file.file), - added: file.binary ? 0 : file.insertions, - removed: file.binary ? 0 : file.deletions, + added: 'insertions' in file ? file.insertions : 0, + removed: 'deletions' in file ? file.deletions : 0, status: 'M', staged: false })) @@ -291,6 +271,7 @@ async function reviewList(repoPath, scope, baseRef, gitBin) { git.diffSummary(['--cached']), git.diffSummary([]) ]) + const stagedCounts = countsByPath(staged) const unstagedCounts = countsByPath(unstaged) @@ -495,6 +476,7 @@ async function reviewCommitContext(repoPath, gitBin) { const safe = args => git.diff(args).catch(() => '') let status + try { status = await git.status() } catch { @@ -510,9 +492,11 @@ async function reviewCommitContext(repoPath, gitBin) { // Untracked files have no diff — list them so new files aren't invisible. const untracked = status.not_added || [] + if (untracked.length > 0) { const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX) const omitted = untracked.length - visible.length + const note = `\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` + (omitted > 0 ? `# ... ${omitted} more omitted\n` : '') @@ -607,6 +591,7 @@ async function repoStatus(repoPath, gitBin) { // fail soft and hide the coding rail instead of spamming IPC handler errors. try { const stat = await fs.stat(cwd) + if (!stat.isDirectory()) { return null } @@ -615,11 +600,13 @@ async function repoStatus(repoPath, gitBin) { } let git + try { git = gitFor(cwd, gitBin) } catch { return null } + let status try { @@ -630,6 +617,7 @@ async function repoStatus(repoPath, gitBin) { } const detached = typeof status.detached === 'boolean' ? status.detached : !status.current + const files = status.files.map(file => ({ path: file.path, staged: isStaged(file), @@ -671,10 +659,12 @@ async function repoStatus(repoPath, gitBin) { // can't stall the probe. try { const untracked = status.not_added.slice(0, 500) + for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) { const batch = await Promise.all( untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path)) ) + result.added += batch.reduce((sum, n) => sum + n, 0) } } catch { @@ -684,7 +674,7 @@ async function repoStatus(repoPath, gitBin) { return result } -module.exports = { +export { branchBase, fileDiffVsHead, repoStatus, @@ -695,8 +685,8 @@ module.exports = { reviewDiff, reviewList, reviewPush, - reviewRevParse, reviewRevert, + reviewRevParse, reviewShipInfo, reviewStage, reviewUnstage diff --git a/apps/desktop/electron/git-root.test.cjs b/apps/desktop/electron/git-root.test.cjs deleted file mode 100644 index ba649b259f3c..000000000000 --- a/apps/desktop/electron/git-root.test.cjs +++ /dev/null @@ -1,40 +0,0 @@ -'use strict' - -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') -const { pathToFileURL } = require('node:url') - -const { gitRootForIpc } = require('./git-root.cjs') - -function mkTmpDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-')) -} - -test('gitRootForIpc returns null for invalid and device paths', async () => { - assert.equal(await gitRootForIpc(''), null) - assert.equal(await gitRootForIpc(' '), null) - assert.equal(await gitRootForIpc(null), null) - assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null) - assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null) -}) - -test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => { - const root = mkTmpDir() - t.after(() => fs.rmSync(root, { recursive: true, force: true })) - - const gitDir = path.join(root, '.git') - const srcDir = path.join(root, 'src') - const filePath = path.join(srcDir, 'index.ts') - fs.mkdirSync(gitDir) - fs.mkdirSync(srcDir) - fs.writeFileSync(filePath, 'export {}\n', 'utf8') - - assert.equal(await gitRootForIpc(root), root) - assert.equal(await gitRootForIpc(srcDir), root) - assert.equal(await gitRootForIpc(filePath), root) - assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root) - assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root) -}) diff --git a/apps/desktop/electron/git-root.test.ts b/apps/desktop/electron/git-root.test.ts new file mode 100644 index 000000000000..e4a1758c3d0c --- /dev/null +++ b/apps/desktop/electron/git-root.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import { test } from 'vitest' + +import { gitRootForIpc } from './git-root' + +function mkTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-')) +} + +test('gitRootForIpc returns null for invalid and device paths', async () => { + assert.equal(await gitRootForIpc(''), null) + assert.equal(await gitRootForIpc(' '), null) + assert.equal(await gitRootForIpc(null), null) + assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null) + assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null) +}) + +test('gitRootForIpc resolves directories files missing descendants and file URLs', async () => { + const root = mkTmpDir() + + try { + const gitDir = path.join(root, '.git') + const srcDir = path.join(root, 'src') + const filePath = path.join(srcDir, 'index.ts') + fs.mkdirSync(gitDir) + fs.mkdirSync(srcDir) + fs.writeFileSync(filePath, 'export {}\n', 'utf8') + + assert.equal(await gitRootForIpc(root), root) + assert.equal(await gitRootForIpc(srcDir), root) + assert.equal(await gitRootForIpc(filePath), root) + assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root) + assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/git-root.cjs b/apps/desktop/electron/git-root.ts similarity index 75% rename from apps/desktop/electron/git-root.cjs rename to apps/desktop/electron/git-root.ts index 593d3531ebce..bd19c25a8fd3 100644 --- a/apps/desktop/electron/git-root.cjs +++ b/apps/desktop/electron/git-root.ts @@ -1,8 +1,7 @@ -'use strict' +import fs from 'node:fs' +import path from 'node:path' -const fs = require('node:fs') -const path = require('node:path') -const { resolveRequestedPathForIpc } = require('./hardening.cjs') +import { resolveRequestedPathForIpc } from './hardening' function findGitRoot(start, fsImpl = fs) { let dir = start @@ -28,7 +27,7 @@ function findGitRoot(start, fsImpl = fs) { return null } -async function gitRootForIpc(startPath, options = {}) { +async function gitRootForIpc(startPath, options: { fs?: typeof fs } = {}) { const fsImpl = options.fs || fs let resolved @@ -48,7 +47,4 @@ async function gitRootForIpc(startPath, options = {}) { } } -module.exports = { - findGitRoot, - gitRootForIpc -} +export { findGitRoot, gitRootForIpc } diff --git a/apps/desktop/electron/git-worktree-ops.test.cjs b/apps/desktop/electron/git-worktree-ops.test.ts similarity index 66% rename from apps/desktop/electron/git-worktree-ops.test.cjs rename to apps/desktop/electron/git-worktree-ops.test.ts index b0865d4ad777..63378c293586 100644 --- a/apps/desktop/electron/git-worktree-ops.test.cjs +++ b/apps/desktop/electron/git-worktree-ops.test.ts @@ -1,20 +1,20 @@ -'use strict' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' -const assert = require('node:assert/strict') -const { execFileSync } = require('node:child_process') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') +import { test } from 'vitest' -const { +import { addWorktree, ensureGitRepo, + listBaseBranches, listBranches, parseWorktrees, sanitizeBranch, switchBranch -} = require('./git-worktree-ops.cjs') +} from './git-worktree-ops' test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => { assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes') @@ -212,3 +212,90 @@ test('addWorktree: existing default branch switches the main checkout, not .work fs.rmSync(dir, { recursive: true, force: true }) } }) + +test('listBaseBranches: lists local branches and flags the default', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-branches-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + const trunk = git('branch', '--show-current') + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + + const branches = await listBaseBranches(dir, 'git') + const names = branches.map(b => b.name).sort() + + assert.deepEqual(names, [trunk, 'feature'].sort()) + // No remote → all local. + assert.equal(branches.every(b => !b.isRemote), true) + // The trunk is flagged as the default. + assert.equal(branches.find(b => b.name === trunk).isDefault, true) + assert.equal(branches.find(b => b.name === 'feature').isDefault, false) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBaseBranches: empty on a non-repo path', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-nonrepo-')) + + try { + assert.deepEqual(await listBaseBranches(dir, 'git'), []) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('addWorktree: base param branches off a specified local branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-add-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'staging'], { cwd: dir }) + + const result = await addWorktree(dir, { base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' }, 'git') + + assert.equal(result.branch, 'new-from-staging') + assert.equal(git('-C', result.path, 'merge-base', 'HEAD', 'staging').length > 0, true) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('addWorktree: base origin/main does not set up upstream tracking', async () => { + // Two repos: a bare "remote" and a clone, so origin/main resolves as a + // remote-tracking ref — the condition that triggers auto-tracking. + const remoteDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-remote-')) + const cloneDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-clone-')) + const git = (...args) => execFileSync('git', args, { cwd: cloneDir }).toString().trim() + + try { + // Seed the remote with a commit on main. Inline identity so it works + // on CI runners with no global git config. + execFileSync('git', ['init', '-b', 'main', remoteDir]) + execFileSync('git', ['-C', remoteDir, '-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'root']) + + // Clone so origin/main exists as a remote-tracking ref. + execFileSync('git', ['clone', remoteDir, cloneDir]) + + const result = await addWorktree(cloneDir, { base: 'origin/main', branch: 'feature-branch', name: 'feature-branch' }, 'git') + + assert.equal(result.branch, 'feature-branch') + + // The new branch must NOT have an upstream — like `git checkout origin/main + // && git checkout -b feature-branch`, not `git worktree add -b … origin/main`. + let hasUpstream = true + + try { + execFileSync('git', ['-C', result.path, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']) + } catch { + hasUpstream = false + } + + assert.equal(hasUpstream, false) + } finally { + fs.rmSync(remoteDir, { recursive: true, force: true }) + fs.rmSync(cloneDir, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/git-worktree-ops.cjs b/apps/desktop/electron/git-worktree-ops.ts similarity index 75% rename from apps/desktop/electron/git-worktree-ops.cjs rename to apps/desktop/electron/git-worktree-ops.ts index de4e01cfb94c..1acd029e1258 100644 --- a/apps/desktop/electron/git-worktree-ops.cjs +++ b/apps/desktop/electron/git-worktree-ops.ts @@ -1,16 +1,14 @@ -'use strict' - // Git-driven worktree operations for the desktop "Start work" flow: spin up a // fresh worktree the lightest way (`git worktree add -b`), list real worktrees, // and remove them. Git is the source of truth; the renderer just drives these. -const path = require('node:path') -const fs = require('node:fs') -const { execFile } = require('node:child_process') +import { execFile } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' -const { resolveRequestedPathForIpc } = require('./hardening.cjs') +import { resolveRequestedPathForIpc } from './hardening' -function runGit(gitBin, args, cwd) { +function runGit(gitBin, args, cwd): Promise { return new Promise((resolve, reject) => { execFile( gitBin, @@ -253,7 +251,32 @@ async function addWorktree(repoPath, options, gitBin) { const args = ['worktree', 'add', '-b', branch, dir] if (opts.base) { - args.push(String(opts.base)) + // Remote-tracking branches may be stale or missing if the user hasn't + // fetched recently. When the base is an `origin/…` ref, fetch just that + // branch so `git worktree add -b new origin/main` works against the + // latest remote commit. Local branches are used as-is. + const base = String(opts.base) + + if (base.startsWith('origin/')) { + const remoteBranch = base.slice('origin/'.length) + + try { + await runGit(gitBin, ['fetch', 'origin', remoteBranch], root) + } catch { + // The fetch isn't mandatory, but it would be nice to do if possible. + // If it's not possible, just use the local ref of the remote branch. + // If it doesn't exist locally, we'll get an error + } + + // When branching off a remote-tracking ref, git auto-sets up tracking + // (e.g. `new-branch` → tracks `origin/main`). The user almost certainly + // wants a standalone local branch — like `git checkout origin/main && + // git checkout -b new-branch` — not a branch silently wired to the + // remote's upstream. `--no-track` prevents that. + args.push('--no-track') + } + + args.push(base) } try { @@ -306,6 +329,7 @@ async function listBranches(repoPath, gitBin) { ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'], resolved ) + const trees = await listWorktrees(resolved, gitBin) const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path])) const trunk = await defaultBranch(gitBin, resolved) @@ -338,9 +362,56 @@ async function switchBranch(repoPath, branch, gitBin) { return { branch: target } } -module.exports = { +// Branches the new worktree can be based on: local heads + remote-tracking +// refs. Listed most-recently-committed first; the remote's default branch +// (origin/HEAD) is flagged so the UI can preselect it. Empty on a non-repo / +// remote backend where the probe can't run. +async function listBaseBranches(repoPath, gitBin) { + let resolved + + try { + resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Base branch list' }) + } catch { + return [] + } + + try { + const out = await runGit( + gitBin, + ['for-each-ref', '--format=%(refname:short)\t%(committerdate:iso)', '--sort=-committerdate', 'refs/heads', 'refs/remotes'], + resolved + ) + + const remoteDefault = await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], resolved) + const localDefault = await defaultBranch(gitBin, resolved) + + return out + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(line => { + const [name] = line.split('\t') + + return { + name, + isRemote: name.startsWith('origin/'), + // origin/HEAD when a remote exists; otherwise the local default + // (main/master/init.defaultBranch) so a no-remote repo still flags + // its trunk. + isDefault: Boolean( + (remoteDefault && name === remoteDefault) || (!remoteDefault && localDefault && name === localDefault) + ) + } + }) + } catch { + return [] + } +} + +export { addWorktree, ensureGitRepo, + listBaseBranches, listBranches, listWorktrees, parseWorktrees, diff --git a/apps/desktop/electron/hardening.test.cjs b/apps/desktop/electron/hardening.test.cjs deleted file mode 100644 index b38a03b00828..000000000000 --- a/apps/desktop/electron/hardening.test.cjs +++ /dev/null @@ -1,279 +0,0 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') -const { pathToFileURL } = require('node:url') - -const { - DEFAULT_FETCH_TIMEOUT_MS, - encryptDesktopSecret, - resolveDirectoryForIpc, - resolveReadableFileForIpc, - resolveRequestedPathForIpc, - resolveTimeoutMs, - sensitiveFileBlockReason -} = require('./hardening.cjs') - -async function rejectsWithCode(promise, code) { - await assert.rejects(promise, error => { - assert.equal(error?.code, code) - return true - }) -} - -test('resolveTimeoutMs falls back to defaults and accepts overrides', () => { - assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS) - assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS) - assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS) - assert.equal(resolveTimeoutMs('2750'), 2750) -}) - -test('encryptDesktopSecret requires available secure storage', () => { - assert.equal( - encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }), - null - ) - - assert.throws( - () => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }), - /Secure token storage is unavailable/ - ) -}) - -test('encryptDesktopSecret stores safeStorage base64 payload', () => { - const secret = encryptDesktopSecret('token-123', { - isEncryptionAvailable: () => true, - encryptString: value => Buffer.from(`enc:${value}`, 'utf8') - }) - - assert.deepEqual(secret, { - encoding: 'safeStorage', - value: Buffer.from('enc:token-123', 'utf8').toString('base64') - }) -}) - -test('sensitiveFileBlockReason blocks obvious secret file patterns', () => { - assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/) - assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null) - assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/) - assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/) -}) - -test('path helpers reject blank non-string NUL and Windows device syntax', async () => { - await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path') - await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path') - await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path') - await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path') - - const devicePaths = [ - '\\\\?\\C:\\secret.txt', - '\\\\.\\C:\\secret.txt', - '\\\\?\\UNC\\server\\share\\secret.txt', - 'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt' - ] - - for (const devicePath of devicePaths) { - assert.throws( - () => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }), - error => { - assert.equal(error?.code, 'device-path') - return true - } - ) - await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path') - } - - assert.throws( - () => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), - error => { - assert.equal(error?.code, 'invalid-path') - return true - } - ) - await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path') -}) - -test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => { - const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base') - - assert.equal( - resolveRequestedPathForIpc('notes.txt', { - baseDir: ` ${baseDir} `, - purpose: 'File preview' - }), - path.resolve(baseDir, 'notes.txt') - ) -}) - -test('resolveRequestedPathForIpc expands ~ to the home directory', () => { - assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir())) - assert.equal( - resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }), - path.resolve(os.homedir(), 'www/project') - ) - // `~user` shorthand is NOT expanded — only the caller's own home. - assert.equal( - resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }), - path.resolve(os.tmpdir(), '~other/secret') - ) -}) - -test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-')) - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) - - const textPath = path.join(tempDir, 'notes.txt') - fs.writeFileSync(textPath, 'hello world', 'utf8') - - const fromRelative = await resolveReadableFileForIpc('notes.txt', { - baseDir: tempDir, - maxBytes: 256, - purpose: 'File preview' - }) - assert.equal(fromRelative.resolvedPath, textPath) - assert.equal(fromRelative.stat.size, 11) - - const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), { - purpose: 'File preview' - }) - assert.equal(fromFileUrl.resolvedPath, textPath) - - const spacedPath = path.join(tempDir, 'notes with spaces.txt') - fs.writeFileSync(spacedPath, 'space ok', 'utf8') - const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), { - purpose: 'File preview' - }) - assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath) - - await assert.rejects( - resolveReadableFileForIpc('missing.txt', { - baseDir: tempDir, - purpose: 'Text preview' - }), - /file does not exist/ - ) - - const nestedDir = path.join(tempDir, 'directory') - fs.mkdirSync(nestedDir) - await assert.rejects( - resolveReadableFileForIpc(nestedDir, { - purpose: 'Text preview' - }), - /path points to a directory/ - ) - - const largePath = path.join(tempDir, 'large.txt') - fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8') - await assert.rejects( - resolveReadableFileForIpc(largePath, { - maxBytes: 8, - purpose: 'File preview' - }), - /file is too large/ - ) - - const envPath = path.join(tempDir, '.env') - fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8') - await assert.rejects( - resolveReadableFileForIpc(envPath, { - purpose: 'File preview' - }), - /blocked for sensitive file/ - ) - - const envTemplatePath = path.join(tempDir, '.env.example') - fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8') - const envTemplate = await resolveReadableFileForIpc(envTemplatePath, { - purpose: 'File preview' - }) - assert.equal(envTemplate.resolvedPath, envTemplatePath) -}) - -test('resolveReadableFileForIpc blocks common sensitive files', async t => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-')) - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) - - const sshDir = path.join(tempDir, '.ssh') - fs.mkdirSync(sshDir) - - const blockedFiles = [ - path.join(tempDir, '.env'), - path.join(tempDir, '.npmrc'), - path.join(sshDir, 'id_ed25519'), - path.join(tempDir, 'cert.pem'), - path.join(tempDir, 'cert.p12'), - path.join(tempDir, 'cert.pfx') - ] - - for (const filePath of blockedFiles) { - fs.writeFileSync(filePath, 'secret', 'utf8') - await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file') - } - - const allowed = path.join(tempDir, '.env.example') - fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8') - assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed) -}) - -test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-')) - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) - - const envPath = path.join(tempDir, '.env') - const linkPath = path.join(tempDir, 'safe-name.txt') - fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8') - - try { - fs.symlinkSync(envPath, linkPath, 'file') - } catch (error) { - if (error?.code === 'EPERM' || error?.code === 'EACCES') { - t.skip(`symlink creation is not permitted on this platform (${error.code})`) - return - } - throw error - } - - await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file') -}) - -test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-')) - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) - - const directory = path.join(tempDir, 'project') - const filePath = path.join(tempDir, 'file.txt') - fs.mkdirSync(directory) - fs.writeFileSync(filePath, 'not a directory', 'utf8') - - const resolved = await resolveDirectoryForIpc(directory) - assert.equal(resolved.resolvedPath, directory) - assert.equal(resolved.stat.isDirectory(), true) - - await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR') - await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT') - await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path') -}) - -test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-')) - t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) - - const directory = path.join(tempDir, 'actual-project') - const linkPath = path.join(tempDir, 'linked-project') - fs.mkdirSync(directory) - - try { - fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir') - } catch (error) { - if (error?.code === 'EPERM' || error?.code === 'EACCES') { - t.skip(`directory symlink creation is not permitted on this platform (${error.code})`) - return - } - throw error - } - - const resolved = await resolveDirectoryForIpc(linkPath) - assert.equal(resolved.resolvedPath, linkPath) - assert.equal(resolved.stat.isDirectory(), true) -}) diff --git a/apps/desktop/electron/hardening.test.ts b/apps/desktop/electron/hardening.test.ts new file mode 100644 index 000000000000..1a5852f720af --- /dev/null +++ b/apps/desktop/electron/hardening.test.ts @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import { test } from 'vitest' + +import { + DEFAULT_FETCH_TIMEOUT_MS, + encryptDesktopSecret, + resolveDirectoryForIpc, + resolveReadableFileForIpc, + resolveRequestedPathForIpc, + resolveTimeoutMs, + sensitiveFileBlockReason +} from './hardening' + +async function rejectsWithCode(promise, code: string) { + await assert.rejects(promise, (error: any) => { + assert.equal(error?.code, code) + + return true + }) +} + +test('resolveTimeoutMs falls back to defaults and accepts overrides', () => { + assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS) + assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS) + assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS) + assert.equal(resolveTimeoutMs('2750'), 2750) +}) + +test('encryptDesktopSecret requires available secure storage', () => { + assert.equal( + encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }), + null + ) + + assert.throws( + () => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }), + /Secure token storage is unavailable/ + ) +}) + +test('encryptDesktopSecret stores safeStorage base64 payload', () => { + const secret = encryptDesktopSecret('token-123', { + isEncryptionAvailable: () => true, + encryptString: value => Buffer.from(`enc:${value}`, 'utf8') + }) + + assert.deepEqual(secret, { + encoding: 'safeStorage', + value: Buffer.from('enc:token-123', 'utf8').toString('base64') + }) +}) + +test('sensitiveFileBlockReason blocks obvious secret file patterns', () => { + assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/) + assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null) + assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/) + assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/) +}) + +test('path helpers reject blank non-string NUL and Windows device syntax', async () => { + await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path') + + const devicePaths = [ + '\\\\?\\C:\\secret.txt', + '\\\\.\\C:\\secret.txt', + '\\\\?\\UNC\\server\\share\\secret.txt', + 'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt' + ] + + for (const devicePath of devicePaths) { + assert.throws( + () => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }), + (error: any) => { + assert.equal(error?.code, 'device-path') + + return true + } + ) + await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path') + } + + assert.throws( + () => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), + (error: any) => { + assert.equal(error?.code, 'invalid-path') + + return true + } + ) + await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path') +}) + +test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => { + const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base') + + assert.equal( + resolveRequestedPathForIpc('notes.txt', { + baseDir: ` ${baseDir} `, + purpose: 'File preview' + }), + path.resolve(baseDir, 'notes.txt') + ) +}) + +test('resolveRequestedPathForIpc expands ~ to the home directory', () => { + assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir())) + assert.equal( + resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }), + path.resolve(os.homedir(), 'www/project') + ) + // `~user` shorthand is NOT expanded — only the caller's own home. + assert.equal( + resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }), + path.resolve(os.tmpdir(), '~other/secret') + ) +}) + +test('resolveReadableFileForIpc validates existence type size and sensitivity', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-')) + + try { + const textPath = path.join(tempDir, 'notes.txt') + fs.writeFileSync(textPath, 'hello world', 'utf8') + + const fromRelative = await resolveReadableFileForIpc('notes.txt', { + baseDir: tempDir, + maxBytes: 256, + purpose: 'File preview' + }) + + assert.equal(fromRelative.resolvedPath, textPath) + assert.equal(fromRelative.stat.size, 11) + + const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), { + purpose: 'File preview' + }) + + assert.equal(fromFileUrl.resolvedPath, textPath) + + const spacedPath = path.join(tempDir, 'notes with spaces.txt') + fs.writeFileSync(spacedPath, 'space ok', 'utf8') + + const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), { + purpose: 'File preview' + }) + + assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath) + + await assert.rejects( + resolveReadableFileForIpc('missing.txt', { + baseDir: tempDir, + purpose: 'Text preview' + }), + /file does not exist/ + ) + + const nestedDir = path.join(tempDir, 'directory') + fs.mkdirSync(nestedDir) + await assert.rejects( + resolveReadableFileForIpc(nestedDir, { + purpose: 'Text preview' + }), + /path points to a directory/ + ) + + const largePath = path.join(tempDir, 'large.txt') + fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8') + await assert.rejects( + resolveReadableFileForIpc(largePath, { + maxBytes: 8, + purpose: 'File preview' + }), + /file is too large/ + ) + + const envPath = path.join(tempDir, '.env') + fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8') + await assert.rejects( + resolveReadableFileForIpc(envPath, { + purpose: 'File preview' + }), + /blocked for sensitive file/ + ) + + const envTemplatePath = path.join(tempDir, '.env.example') + fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8') + + const envTemplate = await resolveReadableFileForIpc(envTemplatePath, { + purpose: 'File preview' + }) + + assert.equal(envTemplate.resolvedPath, envTemplatePath) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('resolveReadableFileForIpc blocks common sensitive files', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-')) + + try { + const sshDir = path.join(tempDir, '.ssh') + fs.mkdirSync(sshDir) + + const blockedFiles = [ + path.join(tempDir, '.env'), + path.join(tempDir, '.npmrc'), + path.join(sshDir, 'id_ed25519'), + path.join(tempDir, 'cert.pem'), + path.join(tempDir, 'cert.p12'), + path.join(tempDir, 'cert.pfx') + ] + + for (const filePath of blockedFiles) { + fs.writeFileSync(filePath, 'secret', 'utf8') + await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file') + } + + const allowed = path.join(tempDir, '.env.example') + fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8') + assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-')) + + try { + const envPath = path.join(tempDir, '.env') + const linkPath = path.join(tempDir, 'safe-name.txt') + fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8') + + try { + fs.symlinkSync(envPath, linkPath, 'file') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + // symlink creation is not permitted on this platform — skip + return + } + + throw error + } + + await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file') + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-')) + + try { + const directory = path.join(tempDir, 'project') + const filePath = path.join(tempDir, 'file.txt') + fs.mkdirSync(directory) + fs.writeFileSync(filePath, 'not a directory', 'utf8') + + const resolved = await resolveDirectoryForIpc(directory) + assert.equal(resolved.resolvedPath, directory) + assert.equal(resolved.stat.isDirectory(), true) + + await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR') + await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT') + await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path') + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('resolveDirectoryForIpc accepts directory symlinks or junctions', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-')) + + try { + const directory = path.join(tempDir, 'actual-project') + const linkPath = path.join(tempDir, 'linked-project') + fs.mkdirSync(directory) + + try { + fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + // directory symlink creation is not permitted on this platform — skip + return + } + + throw error + } + + const resolved = await resolveDirectoryForIpc(linkPath) + assert.equal(resolved.resolvedPath, linkPath) + assert.equal(resolved.stat.isDirectory(), true) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/hardening.cjs b/apps/desktop/electron/hardening.ts similarity index 89% rename from apps/desktop/electron/hardening.cjs rename to apps/desktop/electron/hardening.ts index 574e659f96c3..2d6b53310018 100644 --- a/apps/desktop/electron/hardening.cjs +++ b/apps/desktop/electron/hardening.ts @@ -1,7 +1,7 @@ -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { fileURLToPath } = require('node:url') +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' const DEFAULT_FETCH_TIMEOUT_MS = 15_000 const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024 @@ -13,6 +13,7 @@ const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx']) function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) { const fallback = Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS + const parsed = Number(timeoutMs) if (Number.isFinite(parsed) && parsed > 0) { @@ -62,6 +63,7 @@ function sensitiveFileBlockReason(filePath) { const normalized = String(filePath || '') .replace(/\\/g, '/') .toLowerCase() + const basename = path.basename(normalized) const ext = path.extname(basename) @@ -87,6 +89,7 @@ function sensitiveFileBlockReason(filePath) { if (basename.startsWith('.env.')) { const suffix = basename.slice('.env.'.length) + if (!SAFE_ENV_SUFFIXES.has(suffix)) { return `${basename} is blocked because it appears to contain environment secrets.` } @@ -107,9 +110,11 @@ function sensitiveFileBlockReason(filePath) { return null } -function ipcPathError(code, message) { - const error = new Error(message) - error.code = code +function ipcPathError(code: any, message: string): Error & { code: any } { + const error = new Error(message) as Error & { code: any } + + ;(error as any).code = code + return error } @@ -129,6 +134,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') { } const normalized = raw.replace(/\\/g, '/').toLowerCase() + if ( normalized.startsWith('//?/') || normalized.startsWith('//./') || @@ -141,7 +147,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') { return raw } -function resolveRequestedPathForIpc(filePath, options = {}) { +function resolveRequestedPathForIpc(filePath, options: { purpose?: string; baseDir?: fs.PathOrFileDescriptor } = {}) { const purpose = String(options.purpose || 'File read') let raw = rejectUnsafePathSyntax(filePath, purpose) @@ -154,17 +160,21 @@ function resolveRequestedPathForIpc(filePath, options = {}) { if (/^file:/i.test(raw)) { let resolvedPath + try { const parsed = new URL(raw) + if (parsed.protocol !== 'file:') { throw new Error('not a file URL') } + resolvedPath = fileURLToPath(parsed) } catch { throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`) } rejectUnsafePathSyntax(resolvedPath, purpose) + return path.resolve(resolvedPath) } @@ -178,14 +188,16 @@ function resolveRequestedPathForIpc(filePath, options = {}) { return resolvedPath } -async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) { +async function statForIpc(fsImpl: { promises: { stat: typeof fs.promises.stat } }, resolvedPath, purpose, typeLabel) { try { return await fsImpl.promises.stat(resolvedPath) } catch (error) { const code = error && typeof error === 'object' ? error.code : '' + if (code === 'ENOENT' || code === 'ENOTDIR') { throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`) } + throw ipcPathError( code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}` @@ -201,6 +213,7 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) { try { const realPath = await fsImpl.promises.realpath(resolvedPath) rejectUnsafePathSyntax(realPath, purpose) + return realPath } catch (error) { const code = error && typeof error === 'object' ? error.code : '' @@ -213,12 +226,20 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) { function rejectSensitiveFilePath(filePath, purpose) { const blockReason = sensitiveFileBlockReason(filePath) + if (blockReason) { throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`) } } -async function resolveDirectoryForIpc(dirPath, options = {}) { +async function resolveDirectoryForIpc( + dirPath, + options: { + purpose?: string + baseDir?: fs.PathOrFileDescriptor + fs?: { promises: { stat: typeof fs.promises.stat } } + } = {} +) { const purpose = String(options.purpose || 'Directory read') const fsImpl = options.fs || fs const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose }) @@ -233,7 +254,16 @@ async function resolveDirectoryForIpc(dirPath, options = {}) { return { realPath, resolvedPath, stat } } -async function resolveReadableFileForIpc(filePath, options = {}) { +async function resolveReadableFileForIpc( + filePath, + options: { + purpose?: string + baseDir?: fs.PathOrFileDescriptor + fs?: typeof fs + blockSensitive?: boolean + maxBytes?: number + } = {} +) { const purpose = String(options.purpose || 'File read') const fsImpl = options.fs || fs const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose }) @@ -253,11 +283,13 @@ async function resolveReadableFileForIpc(filePath, options = {}) { } const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose) + if (options.blockSensitive !== false) { rejectSensitiveFilePath(realPath, purpose) } const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null + if (maxBytes && stat.size > maxBytes) { throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`) } @@ -271,15 +303,15 @@ async function resolveReadableFileForIpc(filePath, options = {}) { return { realPath, resolvedPath, stat } } -module.exports = { +export { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, - TEXT_PREVIEW_SOURCE_MAX_BYTES, encryptDesktopSecret, rejectUnsafePathSyntax, resolveDirectoryForIpc, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, - sensitiveFileBlockReason + sensitiveFileBlockReason, + TEXT_PREVIEW_SOURCE_MAX_BYTES } diff --git a/apps/desktop/electron/link-title-window.test.cjs b/apps/desktop/electron/link-title-window.test.ts similarity index 96% rename from apps/desktop/electron/link-title-window.test.cjs rename to apps/desktop/electron/link-title-window.test.ts index 468c646a047e..f81190f47dca 100644 --- a/apps/desktop/electron/link-title-window.test.cjs +++ b/apps/desktop/electron/link-title-window.test.ts @@ -1,15 +1,17 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' -const { +import { test } from 'vitest' + +import { createLinkTitleWindow, guardLinkTitleSession, linkTitleWindowOptions, readLinkTitleWindowTitle -} = require('./link-title-window.cjs') +} from './link-title-window' function makeFakeBrowserWindow() { const calls = { audioMuted: [] } + const FakeBrowserWindow = function (options) { this.options = options this.webContents = { diff --git a/apps/desktop/electron/link-title-window.cjs b/apps/desktop/electron/link-title-window.ts similarity index 80% rename from apps/desktop/electron/link-title-window.cjs rename to apps/desktop/electron/link-title-window.ts index c6792bf989e1..0920976a528b 100644 --- a/apps/desktop/electron/link-title-window.cjs +++ b/apps/desktop/electron/link-title-window.ts @@ -1,11 +1,9 @@ -'use strict' - // Hidden BrowserWindow used by tier-2 link-title resolution: when curl can't // read a page (bot walls, JS-rendered pages), we briefly load the URL // in an offscreen window and read its title. That window loads arbitrary // user-linked pages, so it must never emit sound or trigger real downloads. -function linkTitleWindowOptions(partitionSession) { +export function linkTitleWindowOptions(partitionSession) { return { show: false, width: 1280, @@ -25,7 +23,7 @@ function linkTitleWindowOptions(partitionSession) { // Create the offscreen title-fetch window and immediately mute it. Without the // mute, autoplaying media on the loaded page (e.g. a YouTube link) leaks ~2s of // audio every time a session containing such links is re-rendered. See #49505. -function createLinkTitleWindow(BrowserWindow, partitionSession) { +export function createLinkTitleWindow(BrowserWindow, partitionSession) { const window = new BrowserWindow(linkTitleWindowOptions(partitionSession)) try { @@ -41,7 +39,7 @@ function createLinkTitleWindow(BrowserWindow, partitionSession) { // Cancel any download the title-fetch window triggers. Without this, a link // artifact URL served with Content-Disposition: attachment auto-downloads every // time the Artifacts page renders and fetchLinkTitle loads it. -function guardLinkTitleSession(partitionSession) { +export function guardLinkTitleSession(partitionSession) { try { partitionSession.on('will-download', (_event, item) => item.cancel()) } catch { @@ -52,20 +50,20 @@ function guardLinkTitleSession(partitionSession) { // Read the page title from a title-fetch window. Callers schedule this from // timers that can fire after finish() destroys the window, so every access must // guard isDestroyed and swallow Electron's "Object has been destroyed" throws. -function readLinkTitleWindowTitle(window) { +export function readLinkTitleWindowTitle(window) { try { - if (!window || window.isDestroyed()) return '' + if (!window || window.isDestroyed()) { + return '' + } + const contents = window.webContents - if (!contents || contents.isDestroyed()) return '' + + if (!contents || contents.isDestroyed()) { + return '' + } + return contents.getTitle() || '' } catch { return '' } } - -module.exports = { - createLinkTitleWindow, - guardLinkTitleSession, - linkTitleWindowOptions, - readLinkTitleWindowTitle -} diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.ts similarity index 84% rename from apps/desktop/electron/main.cjs rename to apps/desktop/electron/main.ts index 69d5d5f715bf..de2e7c68ac1a 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.ts @@ -1,14 +1,24 @@ -const { + +import { execFile, execFileSync, spawn } from 'node:child_process' +import crypto from 'node:crypto' +import fs from 'node:fs' +import http from 'node:http' +import https from 'node:https' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import { app, BrowserWindow, - Menu, - Notification, clipboard, dialog, + net as electronNet, ipcMain, + Menu, nativeImage, nativeTheme, - net: electronNet, + Notification, powerMonitor, protocol, safeStorage, @@ -16,58 +26,49 @@ const { session, shell, systemPreferences -} = require('electron') -const crypto = require('node:crypto') -const fs = require('node:fs') -const http = require('node:http') -const https = require('node:https') -const os = require('node:os') -const path = require('node:path') -const { pathToFileURL } = require('node:url') -const { execFileSync, spawn } = require('node:child_process') -const { installEmbedReferer } = require('./embed-referer.cjs') -const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') -const { runBootstrap } = require('./bootstrap-runner.cjs') -const { - buildSessionWindowUrl, - chatWindowWebPreferences, - createSessionWindowRegistry, - SESSION_WINDOW_MIN_HEIGHT, - SESSION_WINDOW_MIN_WIDTH -} = require('./session-windows.cjs') -const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') -const { - createLinkTitleWindow, - guardLinkTitleSession, - readLinkTitleWindowTitle -} = require('./link-title-window.cjs') -const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') -const { adoptServedDashboardToken } = require('./dashboard-token.cjs') -const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs') -const { dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs') -const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') -const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') -const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') -const { readWindowsUserEnvVar } = require('./windows-user-env.cjs') -const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs') -const { - nativeOverlayWidth: computeNativeOverlayWidth, - macTitleBarOverlayHeight -} = require('./titlebar-overlay-width.cjs') -const { readDirForIpc } = require('./fs-read-dir.cjs') -const { readLiveUpdateMarker, writeUpdateMarker } = require('./update-marker.cjs') -const { - resolveUnpackedRelease, - decideRelaunchOutcome, - sandboxPreflight, - sandboxFallbackFromEnv, - collectRelaunchArgs, - collectRelaunchEnv, - buildRelaunchScript -} = require('./update-relaunch.cjs') -const { gitRootForIpc } = require('./git-root.cjs') -const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs') -const { +} from 'electron' +import nodePty from 'node-pty' + +import { stopBackendChild as stopBackendChildImpl } from './backend-child' +import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' +import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env' +import { canImportHermesCli, verifyHermesCli } from './backend-probes' +import { waitForDashboardPortAnnouncement } from './backend-ready' +import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform' +import { runBootstrap } from './bootstrap-runner' +import { + authModeFromStatus, + buildGatewayWsUrl, + buildGatewayWsUrlWithTicket, + connectionScopeKey, + cookiesHaveLiveSession, + cookiesHavePrivySession, + cookiesHaveSession, + modeIsRemoteLike, + normalizeRemoteBaseUrl, + normAuthMode, + pathWithGlobalRemoteProfile, + profileRemoteOverride, + resolveAuthMode, + resolveTestWsUrl, + tokenPreview +} from './connection-config' +import { adoptServedDashboardToken } from './dashboard-token' +import { + buildPosixCleanupScript, + buildWindowsCleanupScript, + modeRemovesAgent, + modeRemovesUserData, + resolveRemovableAppPath, + shouldRemoveAppBundle, + uninstallArgsForMode +} from './desktop-uninstall' +import { installEmbedReferer } from './embed-referer' +import { readDirForIpc } from './fs-read-dir' +import { resolvePickerDefaultPath } from './wsl-path-bridge' +import { probeGatewayWebSocket } from './gateway-ws-probe' +import { scanGitRepos } from './git-repo-scan' +import { fileDiffVsHead, repoStatus, reviewCommit, @@ -76,87 +77,63 @@ const { reviewDiff, reviewList, reviewPush, - reviewRevParse, reviewRevert, + reviewRevParse, reviewShipInfo, reviewStage, reviewUnstage -} = require('./git-review-ops.cjs') -const { scanGitRepos } = require('./git-repo-scan.cjs') -const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') -const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') -const { runRebuildWithRetry } = require('./update-rebuild.cjs') -const { - buildPosixCleanupScript, - buildWindowsCleanupScript, - modeRemovesAgent, - modeRemovesUserData, - resolveRemovableAppPath, - shouldRemoveAppBundle, - uninstallArgsForMode -} = require('./desktop-uninstall.cjs') -const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs') -const { - MIN_WIDTH: WINDOW_MIN_WIDTH, - MIN_HEIGHT: WINDOW_MIN_HEIGHT, - sanitizeWindowState, - computeWindowOptions, - debounce -} = require('./window-state.cjs') -const { - authModeFromStatus, - buildGatewayWsUrl, - buildGatewayWsUrlWithTicket, - connectionScopeKey, - cookiesHaveSession, - cookiesHaveLiveSession, - normAuthMode, - normalizeRemoteBaseUrl, - pathWithGlobalRemoteProfile, - profileRemoteOverride, - resolveAuthMode, - resolveTestWsUrl, - tokenPreview -} = require('./connection-config.cjs') -const { +} from './git-review-ops' +import { gitRootForIpc } from './git-root' +import { addWorktree, listBaseBranches, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops' +import { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, - TEXT_PREVIEW_SOURCE_MAX_BYTES, - encryptDesktopSecret: encryptDesktopSecretStrict, + encryptDesktopSecret as encryptDesktopSecretStrict, resolveReadableFileForIpc, resolveRequestedPathForIpc, - resolveTimeoutMs -} = require('./hardening.cjs') - -let nodePty = null -let nodePtyDir = null - -try { - nodePty = require('node-pty') - nodePtyDir = path.dirname(require.resolve('node-pty/package.json')) -} catch { - // Packaged builds set `files:` in package.json, which excludes node_modules - // from the asar. Workspace dedup also hoists this native dep to the repo - // root's node_modules, out of reach of electron-builder's collector. We - // ship a minimal copy under resources/native-deps/ via extraResources + - // scripts/stage-native-deps.cjs; resolve from there when the normal - // require() fails. Dev mode never reaches this branch -- the hoisted - // resolve succeeds via Node's normal module lookup. - try { - const path = require('node:path') - const resourcesPath = process.resourcesPath - if (resourcesPath) { - nodePtyDir = path.join(resourcesPath, 'native-deps', 'node-pty') - nodePty = require(nodePtyDir) - } - } catch { - console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`) - nodePty = null - nodePtyDir = null - } -} + resolveTimeoutMs, + TEXT_PREVIEW_SOURCE_MAX_BYTES +} from './hardening' +import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' +import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' +import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' +import { + buildSessionWindowUrl, + chatWindowWebPreferences, + createSessionWindowRegistry, + SESSION_WINDOW_MIN_HEIGHT, + SESSION_WINDOW_MIN_WIDTH +} from './session-windows' +import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' +import { resolveBehindCount, shouldCountCommits } from './update-count' +import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker' +import { runRebuildWithRetry } from './update-rebuild' +import { + buildRelaunchScript, + collectRelaunchArgs, + collectRelaunchEnv, + decideRelaunchOutcome, + resolveUnpackedRelease, + sandboxFallbackFromEnv, + sandboxPreflight +} from './update-relaunch' +import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' +import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' +import { + computeWindowOptions, + debounce, + sanitizeWindowState, + MIN_HEIGHT as WINDOW_MIN_HEIGHT, + MIN_WIDTH as WINDOW_MIN_WIDTH +} from './window-state' +import { hiddenWindowsChildOptions } from './windows-child-options' +import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path' +import { readWindowsUserEnvVar } from './windows-user-env' +import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd' +import { readWslWindowsClipboardImage } from './wsl-clipboard-image' const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR + if (USER_DATA_OVERRIDE) { const resolvedUserData = path.resolve(USER_DATA_OVERRIDE) fs.mkdirSync(resolvedUserData, { recursive: true }) @@ -164,7 +141,7 @@ if (USER_DATA_OVERRIDE) { } const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER -const IS_PACKAGED = app.isPackaged +const IS_PACKAGED = app.isPackaged || Boolean(process.env.HERMES_DESKTOP_IS_PACKAGED) const IS_MAC = process.platform === 'darwin' const IS_WINDOWS = process.platform === 'win32' const IS_WSL = isWslEnvironment() @@ -173,12 +150,10 @@ const IS_WSL = isWslEnvironment() const DARWIN_MAJOR = IS_MAC ? Number.parseInt(os.release(), 10) || 0 : 0 const APP_ROOT = app.getAppPath() -function hiddenWindowsChildOptions(options = {}) { - if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { - return options - } - return { ...options, windowsHide: true } -} +// Preload must be plain JS — Electron's sandbox can't run .ts, and tsx's +// ESM loader is broken on Electron 40's Node (ERR_INVALID_RETURN_PROPERTY_VALUE). +// Dev (`npm run dev`) and prod both load the esbuild output from dist/. +const PRELOAD_PATH = path.join(APP_ROOT, 'dist', 'electron-preload.js') // Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU // compositor flicker — accelerated layers can't be presented cleanly over the @@ -190,6 +165,7 @@ function hiddenWindowsChildOptions(options = {}) { // switches only apply pre-launch. Override with HERMES_DESKTOP_DISABLE_GPU // (1/true → always disable, 0/false → keep GPU on). const REMOTE_DISPLAY_REASON = detectRemoteDisplay() + if (REMOTE_DISPLAY_REASON) { app.disableHardwareAcceleration() // Belt-and-suspenders for X11/VNC, where the Viz compositor can still glitch @@ -229,7 +205,7 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') // Build-time install stamp -- the git ref this .exe was built against. // -// Written by apps/desktop/scripts/write-build-stamp.cjs during `npm run build` +// Written by apps/desktop/scripts/write-build-stamp.mjs during `npm run build` // and bundled into packaged apps via electron-builder's extraResources entry, // so the runtime stamp ends up at process.resourcesPath/install-stamp.json // after install. The bootstrap runner (Phase 1D) reads it to know which @@ -241,6 +217,7 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') // Schema: // { schemaVersion: 1, commit, branch, builtAt, dirty, source } const INSTALL_STAMP_SCHEMA_VERSION = 1 + function loadInstallStamp() { // Try packaged location first (resources/install-stamp.json), then the // dev/local build output (apps/desktop/build/install-stamp.json) so @@ -250,17 +227,21 @@ function loadInstallStamp() { process.resourcesPath ? path.join(process.resourcesPath, 'install-stamp.json') : null, path.join(APP_ROOT, 'build', 'install-stamp.json') ].filter(Boolean) + for (const p of candidates) { try { const raw = fs.readFileSync(p, 'utf8') const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && typeof parsed.commit === 'string' && parsed.commit.length >= 7) { if (parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) { console.warn( `[hermes] install-stamp.json schemaVersion ${parsed.schemaVersion} != expected ${INSTALL_STAMP_SCHEMA_VERSION}; ignoring` ) + continue } + return Object.freeze({ schemaVersion: parsed.schemaVersion, commit: parsed.commit, @@ -271,13 +252,17 @@ function loadInstallStamp() { path: p }) } - } catch { + } catch (e) { + console.warn(`[hermes] install-stamp.json found at ${p} , but parsing failed with ${e}`) // Either ENOENT or malformed JSON; try the next candidate } } + return null } + const INSTALL_STAMP = loadInstallStamp() + if (INSTALL_STAMP) { console.log( `[hermes] install stamp: ${INSTALL_STAMP.commit.slice(0, 12)}${INSTALL_STAMP.branch ? ` (${INSTALL_STAMP.branch})` : ''}${INSTALL_STAMP.dirty ? ' [DIRTY]' : ''} from ${INSTALL_STAMP.source || 'unknown'}` @@ -306,8 +291,14 @@ if (INSTALL_STAMP) { // HERMES_HOME beneath the throwaway userData dir so a fresh-install run never // touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes. function resolveHermesHome() { - if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME) - if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') + if (process.env.HERMES_HOME) { + return normalizeHermesHomeRoot(process.env.HERMES_HOME) + } + + if (USER_DATA_OVERRIDE) { + return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') + } + if (IS_WINDOWS) { // A GUI app launched from Explorer inherits the environment block captured // at login, so a HERMES_HOME set via `setx` AFTER login is invisible in @@ -316,16 +307,25 @@ function resolveHermesHome() { // inference provider configured" despite a valid configured home (#45471). // Consult the live User-scoped registry value before the default below. const fromRegistry = readWindowsUserEnvVar('HERMES_HOME') - if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry) + + if (fromRegistry) { + return normalizeHermesHomeRoot(fromRegistry) + } } + if (IS_WINDOWS && process.env.LOCALAPPDATA) { const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes') const legacy = path.join(app.getPath('home'), '.hermes') + // Migrate transparently to LOCALAPPDATA, but honour an existing legacy // ~/.hermes setup (no LOCALAPPDATA install yet) so users don't lose state. - if (!directoryExists(localappdata) && directoryExists(legacy)) return legacy + if (!directoryExists(localappdata) && directoryExists(legacy)) { + return legacy + } + return localappdata } + return path.join(app.getPath('home'), '.hermes') } @@ -338,6 +338,7 @@ function hermesManagedNodePathEntries() { const root = path.join(HERMES_HOME, 'node') const bin = path.join(root, 'bin') const entries = IS_WINDOWS ? [root, bin] : [bin, root] + return entries.filter(directoryExists) } @@ -408,19 +409,27 @@ const DESKTOP_LOG_BACKUP_COUNT = 3 const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4 const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}` const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1' + const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) - if (!Number.isFinite(raw) || raw <= 0) return 650 + + if (!Number.isFinite(raw) || raw <= 0) { + return 650 + } + return Math.max(120, raw) })() -const APP_NAME = 'Hermes' + +const APP_NAME = process.env.HERMES_DESKTOP_APP_NAME || 'Hermes' const TITLEBAR_HEIGHT = 34 const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14 + const WINDOW_BUTTON_POSITION = { x: 24, y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2 } -// Right-edge window-control reservation lives in titlebar-overlay-width.cjs + +// Right-edge window-control reservation lives in titlebar-overlay-width.ts // (pure + unit-testable); computeNativeOverlayWidth() applies it per platform. // It's only the pre-layout fallback — the renderer measures the exact overlay // width live via the Window Controls Overlay API. @@ -574,6 +583,7 @@ function getTitleBarOverlayOptions() { // setTitleBarOverlay isn't supported. function applyTitleBarOverlay(win) { const options = getTitleBarOverlayOptions() + if (!options || typeof options !== 'object') { return } @@ -611,6 +621,7 @@ const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm']) const PREVIEW_WATCH_DEBOUNCE_MS = 120 const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost']) const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 + const PREVIEW_LANGUAGE_BY_EXT = { '.c': 'c', '.conf': 'ini', @@ -647,14 +658,21 @@ const PREVIEW_LANGUAGE_BY_EXT = { } function looksBinary(buffer) { - if (!buffer.length) return false + if (!buffer.length) { + return false + } let suspicious = 0 for (const byte of buffer) { - if (byte === 0) return true + if (byte === 0) { + return true + } + // Allow common whitespace controls: tab, LF, CR. - if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) suspicious += 1 + if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) { + suspicious += 1 + } } return suspicious / buffer.length > 0.12 @@ -691,6 +709,7 @@ function previewFileMetadata(filePath, mimeType) { } app.setName(APP_NAME) + // Windows toast notifications silently no-op unless an AppUserModelID is set: // `new Notification().show()` returns without error and nothing appears. The // AUMID must match the installed Start Menu shortcut's AUMID, which @@ -701,6 +720,7 @@ app.setName(APP_NAME) if (IS_WINDOWS) { app.setAppUserModelId('com.nousresearch.hermes') } + // Seed the native About panel with the live Hermes version. This is refreshed // on every open via the explicit "About" menu handler (refreshAboutPanel), so // an in-place `hermes update` mid-session is reflected without an app restart; @@ -718,6 +738,7 @@ app.setAboutPanelOptions({ // handler removes the size cap and gives the <video> element seekable, // range-aware playback. Must be registered before the app is ready. const MEDIA_PROTOCOL = 'hermes-media' + // Only audio/video may be streamed. Without this the handler would read any // non-blocklisted local file (no size cap) for any `fetch(hermes-media://…)`. const STREAMABLE_MEDIA_EXTS = new Set([ @@ -749,9 +770,12 @@ protocol.registerSchemesAsPrivileged([ function registerMediaProtocol() { protocol.handle(MEDIA_PROTOCOL, async request => { let resolvedPath + try { const url = new URL(request.url) + const filePath = decodeURIComponent(url.pathname.replace(/^\/+/, '')) + ;({ resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' })) } catch { return new Response('Media not found', { status: 404 }) @@ -774,6 +798,9 @@ function registerMediaProtocol() { let mainWindow = null let hermesProcess = null let connectionPromise = null +// True while connection-config:apply soft-rehomes the primary — suppresses the +// backend-exit toast so an intentional kill doesn't look like a crash. +let softRehomeInProgress = false // Additional per-profile backends, keyed by profile name. The PRIMARY backend // (the desktop's launch profile) stays managed by hermesProcess + // connectionPromise + startHermes(); this pool only holds EXTRA profile @@ -820,6 +847,7 @@ let desktopLogBuffer = '' let desktopLogFlushTimer = null let desktopLogFlushPromise = Promise.resolve() let nativeThemeListenerInstalled = false + let bootProgressState = { error: null, fakeMode: BOOT_FAKE_MODE, @@ -834,32 +862,45 @@ let bootProgressState = { // Each step is ['rm', path] or ['mv', src, dst]; executed best-effort so a // missing chain link never aborts the rest. function planDesktopLogRotation(size) { - if (size < DESKTOP_LOG_MAX_BYTES) return [] + if (size < DESKTOP_LOG_MAX_BYTES) { + return [] + } + const backups = n => Array.from({ length: n }, (_, i) => desktopLogBackupPath(i + 1)) + // Pathological boot-loop log: reclaim live + every backup outright. if (size > DESKTOP_LOG_DISCARD_BYTES) { return [DESKTOP_LOG_PATH, ...backups(DESKTOP_LOG_BACKUP_COUNT)].map(p => ['rm', p]) } + // Cascade: drop oldest, shift each up, live -> .1. const ops = [['rm', desktopLogBackupPath(DESKTOP_LOG_BACKUP_COUNT)]] + for (let i = DESKTOP_LOG_BACKUP_COUNT - 1; i >= 1; i--) { ops.push(['mv', desktopLogBackupPath(i), desktopLogBackupPath(i + 1)]) } + ops.push(['mv', DESKTOP_LOG_PATH, desktopLogBackupPath(1)]) + return ops } function rotateDesktopLogIfNeededSync() { let size + try { size = fs.statSync(DESKTOP_LOG_PATH).size } catch { return // No live file yet — the append (re)creates it. } + for (const [op, src, dst] of planDesktopLogRotation(size)) { try { - if (op === 'rm') fs.rmSync(src, { force: true }) - else fs.renameSync(src, dst) + if (op === 'rm') { + fs.rmSync(src, { force: true }) + } else { + fs.renameSync(src, dst) + } } catch { // Best-effort — logging must never block startup/shutdown. } @@ -868,15 +909,20 @@ function rotateDesktopLogIfNeededSync() { async function rotateDesktopLogIfNeededAsync() { let size + try { size = (await fs.promises.stat(DESKTOP_LOG_PATH)).size } catch { return // No live file yet — the append (re)creates it. } + for (const [op, src, dst] of planDesktopLogRotation(size)) { try { - if (op === 'rm') await fs.promises.rm(src, { force: true }) - else await fs.promises.rename(src, dst) + if (op === 'rm') { + await fs.promises.rm(src, { force: true }) + } else { + await fs.promises.rename(src, dst) + } } catch { // Best-effort — logging must never crash the shell. } @@ -884,7 +930,10 @@ async function rotateDesktopLogIfNeededAsync() { } function flushDesktopLogBufferSync() { - if (!desktopLogBuffer) return + if (!desktopLogBuffer) { + return + } + const chunk = desktopLogBuffer desktopLogBuffer = '' @@ -898,7 +947,10 @@ function flushDesktopLogBufferSync() { } function flushDesktopLogBufferAsync() { - if (!desktopLogBuffer) return desktopLogFlushPromise + if (!desktopLogBuffer) { + return desktopLogFlushPromise + } + const chunk = desktopLogBuffer desktopLogBuffer = '' @@ -916,7 +968,10 @@ function flushDesktopLogBufferAsync() { } function scheduleDesktopLogFlush() { - if (desktopLogFlushTimer) return + if (desktopLogFlushTimer) { + return + } + desktopLogFlushTimer = setTimeout(() => { desktopLogFlushTimer = null void flushDesktopLogBufferAsync() @@ -925,9 +980,14 @@ function scheduleDesktopLogFlush() { function rememberLog(chunk) { const text = String(chunk || '').trim() - if (!text) return + + if (!text) { + return + } + const lines = text.split(/\r?\n/).map(line => `[hermes] ${line}`) hermesLog.push(...lines) + if (hermesLog.length > 300) { hermesLog.splice(0, hermesLog.length - 300) } @@ -939,6 +999,7 @@ function rememberLog(chunk) { clearTimeout(desktopLogFlushTimer) desktopLogFlushTimer = null } + void flushDesktopLogBufferAsync() return @@ -949,9 +1010,13 @@ function rememberLog(chunk) { function openExternalUrl(rawUrl) { const raw = String(rawUrl || '').trim() - if (!raw) return false + + if (!raw) { + return false + } let parsed + try { parsed = new URL(raw) } catch { @@ -965,6 +1030,7 @@ function openExternalUrl(rawUrl) { // string), fall back to revealing the file in the system file manager. if (parsed.protocol === 'file:') { let localPath + try { localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' }) } catch { @@ -999,11 +1065,13 @@ function openExternalUrl(rawUrl) { if (IS_WSL) { rememberLog(`[link] opening via WSL→Windows: ${url}`) + const proc = spawn('cmd.exe', ['/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true }) + proc.on('error', error => { rememberLog(`[link] cmd.exe start failed: ${error.message}; falling back to xdg-open`) shell.openExternal(url).catch(fallback => rememberLog(`[link] xdg-open failed: ${fallback.message}`)) @@ -1020,9 +1088,13 @@ function openExternalUrl(rawUrl) { async function openPreviewInBrowser(rawUrl) { const raw = String(rawUrl || '').trim() - if (!raw) return false + + if (!raw) { + return false + } let parsed + try { parsed = new URL(raw) } catch { @@ -1031,6 +1103,7 @@ async function openPreviewInBrowser(rawUrl) { if (parsed.protocol === 'file:') { let localPath + try { localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open preview in browser' }) } catch { @@ -1046,7 +1119,9 @@ async function openPreviewInBrowser(rawUrl) { } function ensureWslWindowsFonts() { - if (!IS_WSL) return + if (!IS_WSL) { + return + } const fontsDir = ['/mnt/c/Windows/Fonts', '/mnt/c/windows/fonts'].find(candidate => { try { @@ -1055,18 +1130,25 @@ function ensureWslWindowsFonts() { return false } }) - if (!fontsDir) return + + if (!fontsDir) { + return + } try { const confDir = path.join(app.getPath('home'), '.config', 'fontconfig', 'conf.d') const confPath = path.join(confDir, '99-hermes-wsl-windows-fonts.conf') let existing = '' + try { existing = fs.readFileSync(confPath, 'utf8') } catch { existing = '' } - if (existing.includes(fontsDir)) return + + if (existing.includes(fontsDir)) { + return + } fs.mkdirSync(confDir, { recursive: true }) fs.writeFileSync( @@ -1089,14 +1171,25 @@ function sleep(ms) { function clampBootProgress(value) { const numeric = Number(value) - if (!Number.isFinite(numeric)) return 0 + + if (!Number.isFinite(numeric)) { + return 0 + } + return Math.max(0, Math.min(100, Math.round(numeric))) } function broadcastBootProgress() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:boot-progress', bootProgressState) } @@ -1120,6 +1213,7 @@ function broadcastBootProgress() { // 'Copy output' button gives the user actually-actionable context, not // just the last few lines. const BOOTSTRAP_LOG_RING_MAX = 500 + let bootstrapState = { active: false, manifest: null, @@ -1137,6 +1231,7 @@ function broadcastBootstrapEvent(ev) { bootstrapState.active = true bootstrapState.startedAt = bootstrapState.startedAt || Date.now() bootstrapState.stages = {} + for (const stage of ev.stages || []) { bootstrapState.stages[stage.name] = { state: 'pending', json: null, durationMs: null, error: null } } @@ -1149,6 +1244,7 @@ function broadcastBootstrapEvent(ev) { } } else if (ev.type === 'log') { bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line, stream: ev.stream || 'stdout' }) + if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) { bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX) } @@ -1170,9 +1266,16 @@ function broadcastBootstrapEvent(ev) { } } - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:bootstrap:event', ev) } @@ -1180,9 +1283,10 @@ function getBootstrapState() { return bootstrapState } -function updateBootProgress(update, options = {}) { +function updateBootProgress(update, options: { allowDecrease?: boolean } = {}) { const nextProgressRaw = typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress + const nextProgress = options.allowDecrease ? nextProgressRaw : Math.max(bootProgressState.progress, nextProgressRaw) bootProgressState = { @@ -1242,7 +1346,7 @@ function directoryExists(filePath) { // cycle loops. Instead the fresh instance parks until the update finishes, then // brings the backend up itself (it is the surviving instance — the updater's // own relaunch hits our single-instance lock and quits). Marker parsing + -// staleness self-heal live in update-marker.cjs (unit-tested). +// staleness self-heal live in update-marker.ts (unit-tested). // How long we'll park the launch waiting for a live update to finish before // giving up and starting the backend anyway (belt-and-suspenders alongside the @@ -1263,10 +1367,14 @@ const UPDATE_HANDOFF_DWELL_MS = 2500 // rather than a frozen splash. Returns true if it parked at all. async function waitForUpdateToFinish() { let marker = readLiveUpdateMarker(HERMES_HOME) - if (!marker) return false + + if (!marker) { + return false + } rememberLog(`[updates] update in progress (pid=${marker.pid}); deferring backend start until it finishes`) const deadline = Date.now() + UPDATE_WAIT_TIMEOUT_MS + while (marker && Date.now() < deadline) { await advanceBootProgress( 'backend.update-wait', @@ -1276,11 +1384,13 @@ async function waitForUpdateToFinish() { await new Promise(r => setTimeout(r, UPDATE_WAIT_POLL_MS)) marker = readLiveUpdateMarker(HERMES_HOME) } + if (marker) { rememberLog('[updates] update still in progress after wait timeout; starting backend anyway') } else { rememberLog('[updates] update finished; proceeding with backend start') } + return true } @@ -1289,31 +1399,41 @@ function unpackedPathFor(filePath) { } function findOnPath(command) { - if (!command) return null + if (!command) { + return null + } if (path.isAbsolute(command) || command.includes(path.sep) || (IS_WINDOWS && command.includes('/'))) { - if (!fileExists(command)) return null - if (isWindowsBinaryPathInWsl(command, { isWsl: IS_WSL })) return null + if (!fileExists(command)) { + return null + } + + if (isWindowsBinaryPathInWsl(command, { isWsl: IS_WSL })) { + return null + } + return command } const pathEntries = String(process.env.PATH || '') .split(path.delimiter) .filter(Boolean) + // On Windows, try PATHEXT extensions BEFORE the bare (empty-extension) name. // A real command must resolve via its .exe/.cmd (Windows command-resolution // semantics consult PATHEXT); an extensionless file — e.g. a Git-Bash // shell-script shim named `hermes` — must not shadow `hermes.cmd`/`hermes.exe`. // The empty entry is kept LAST so callers that already include the extension // (py.exe, pwsh.exe, powershell.exe) still resolve. - const extensions = IS_WINDOWS - ? [...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), ''] - : [''] + const extensions = buildPathExtCandidates(process.env.PATHEXT, IS_WINDOWS) for (const entry of pathEntries) { for (const extension of extensions) { const candidate = path.join(entry, `${command}${extension}`) - if (fileExists(candidate)) return candidate + + if (fileExists(candidate)) { + return candidate + } } } @@ -1325,57 +1445,21 @@ function isCommandScript(command) { } function unwrapWindowsVenvHermesCommand(command, backendArgs) { - if (!IS_WINDOWS || !command || isCommandScript(command)) return null - - const resolved = path.resolve(String(command)) - if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) return null - - const scriptsDir = path.dirname(resolved) - if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null - - const venvRoot = path.dirname(scriptsDir) - const python = getVenvPython(venvRoot) - if (!fileExists(python)) return null - - const root = path.dirname(venvRoot) - - // Smoke-test the venv interpreter before trusting it. A venv whose update - // died mid-`pip install` still has python.exe + hermes.exe on disk, but the - // backend dies on its first import (e.g. ModuleNotFoundError: dotenv) before - // the gateway ever binds. Returning it here also BYPASSED the caller's - // `--version` probe, so Retry/"Repair install" re-resolved the same broken - // venv forever instead of falling through to the bootstrap installer. - // Mirror isActiveRuntimeUsable(): probe with the checkout on PYTHONPATH so a - // healthy source-tree venv passes. - if ( - !canImportHermesCli(python, { - env: { - PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) - } - }) - ) { - rememberLog( - `Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.` - ) - return null - } - - return { - label: `existing Hermes Python at ${python}`, - command: python, - args: ['-m', 'hermes_cli.main', ...backendArgs], - bootstrap: false, - env: buildDesktopBackendEnv({ - hermesHome: HERMES_HOME, - pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)], - venvRoot - }), - kind: 'python', - // Surfaced so backendSupportsServe() can read this runtime's source for the - // `serve` capability check instead of falling back to a heavyweight probe. - root, - shell: false - } + return resolveVenvHermesCommand(command, backendArgs, { + isWindows: IS_WINDOWS, + isCommandScript, + fileExists, + directoryExists, + canImportHermesCli, + getVenvPython, + getVenvSitePackagesEntries, + buildDesktopBackendEnv, + hermesHome: HERMES_HOME, + resolvePath: (...segments) => path.resolve(...segments), + dirname: p => path.dirname(p), + basename: p => path.basename(p), + rememberLog + }) } // Does the resolved runtime understand the `serve` subcommand? The desktop @@ -1389,12 +1473,20 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { // (covers a bare `hermes` resolved from PATH with no known source root). Result // is cached per resolved runtime so we probe at most once per backend. const _serveSupportCache = new Map() + function backendSupportsServe(backend) { - if (!backend || !backend.command) return true + if (!backend || !backend.command) { + return true + } + const key = `${backend.command}::${backend.root || ''}` - if (_serveSupportCache.has(key)) return _serveSupportCache.get(key) + + if (_serveSupportCache.has(key)) { + return _serveSupportCache.get(key) + } let supported = null + if (backend.root) { try { const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8') @@ -1424,6 +1516,7 @@ function backendSupportsServe(backend) { rememberLog( `[backend] \`serve\` ${supported ? 'supported' : 'unsupported → routing via legacy `dashboard`'} for ${backend.label || key}` ) + return supported } @@ -1435,9 +1528,12 @@ function getBackendArgsForRuntime(backend) { } function normalizeExecutablePathForCompare(commandPath) { - if (!commandPath) return null + if (!commandPath) { + return null + } let resolved = path.resolve(String(commandPath)) + try { resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved) } catch { @@ -1448,15 +1544,19 @@ function normalizeExecutablePathForCompare(commandPath) { } function looksLikeDesktopAppBinary(commandPath) { - if (!IS_WINDOWS || !commandPath) return false + if (!IS_WINDOWS || !commandPath) { + return false + } const normalizedCandidate = normalizeExecutablePathForCompare(commandPath) const normalizedCurrentExec = normalizeExecutablePathForCompare(process.execPath) + if (normalizedCandidate && normalizedCurrentExec && normalizedCandidate === normalizedCurrentExec) { return true } let resolved = path.resolve(String(commandPath)) + try { resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved) } catch { @@ -1464,6 +1564,7 @@ function looksLikeDesktopAppBinary(commandPath) { } const resourcesDir = path.join(path.dirname(resolved), 'resources') + return ( fileExists(path.join(resourcesDir, 'app.asar')) || directoryExists(path.join(resourcesDir, 'app.asar.unpacked')) ) @@ -1475,7 +1576,10 @@ function isHermesSourceRoot(root) { function findPythonForRoot(root) { const override = process.env.HERMES_DESKTOP_PYTHON - if (override && fileExists(override)) return override + + if (override && fileExists(override)) { + return override + } const relativePaths = IS_WINDOWS ? [path.join('.venv', 'Scripts', 'python.exe'), path.join('venv', 'Scripts', 'python.exe')] @@ -1483,7 +1587,10 @@ function findPythonForRoot(root) { for (const relativePath of relativePaths) { const candidate = path.join(root, relativePath) - if (fileExists(candidate)) return candidate + + if (fileExists(candidate)) { + return candidate + } } return findSystemPython() @@ -1494,8 +1601,12 @@ function findSystemPython() { // POSIX systems: PATH lookup is safe. for (const command of ['python3', 'python']) { const candidate = findOnPath(command) - if (candidate) return candidate + + if (candidate) { + return candidate + } } + return null } @@ -1551,12 +1662,17 @@ function findSystemPython() { ['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'], hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) ) + // Output format: " (Default) REG_SZ C:\Path\To\Python\" const match = out.match(/REG_SZ\s+(.+?)\s*$/m) + if (match) { const installPath = match[1].trim() const pythonExe = path.join(installPath, 'python.exe') - if (fileExists(pythonExe)) return pythonExe + + if (fileExists(pythonExe)) { + return pythonExe + } } } catch { // Key not present — try next. @@ -1567,12 +1683,20 @@ function findSystemPython() { // Pass 2: filesystem probe of standard locations. const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files' const localAppData = process.env.LOCALAPPDATA || '' + for (const versionDir of SUPPORTED_VERSIONS_NO_DOT) { const systemWide = path.join(programFiles, `Python${versionDir}`, 'python.exe') - if (fileExists(systemWide)) return systemWide + + if (fileExists(systemWide)) { + return systemWide + } + if (localAppData) { const perUser = path.join(localAppData, 'Programs', 'Python', `Python${versionDir}`, 'python.exe') - if (fileExists(perUser)) return perUser + + if (fileExists(perUser)) { + return perUser + } } } @@ -1582,6 +1706,7 @@ function findSystemPython() { // the requested version. We try in version-priority order so the // first hit wins. const pyExe = findOnPath('py.exe') + if (pyExe) { for (const version of SUPPORTED_VERSIONS) { try { @@ -1593,8 +1718,12 @@ function findSystemPython() { stdio: ['ignore', 'pipe', 'ignore'] }) ) + const candidate = out.trim() - if (candidate && fileExists(candidate)) return candidate + + if (candidate && fileExists(candidate)) { + return candidate + } } catch { // py couldn't find that version — try next. } @@ -1628,6 +1757,7 @@ function findGitBash() { // start probing system-wide locations. const localAppData = process.env.LOCALAPPDATA || '' const candidates = [] + if (localAppData) { candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'bash.exe')) candidates.push(path.join(localAppData, 'hermes', 'git', 'usr', 'bin', 'bash.exe')) @@ -1636,12 +1766,15 @@ function findGitBash() { // Standard Git for Windows install locations. candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'bin', 'bash.exe')) candidates.push(path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe')) + if (localAppData) { candidates.push(path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe')) } for (const candidate of candidates) { - if (fileExists(candidate)) return candidate + if (fileExists(candidate)) { + return candidate + } } // Last resort — bash on PATH (covers WSL bash, MSYS2, custom installs). @@ -1675,35 +1808,10 @@ function getVenvPython(venvRoot) { // normal HERMES_DASHBOARD_READY stdout line and no ready-file side channel is // needed. -function getVenvSitePackagesEntries(venvRoot) { - const entries = [] - if (!venvRoot) return entries - - if (IS_WINDOWS) { - const sitePackages = path.join(venvRoot, 'Lib', 'site-packages') - if (directoryExists(sitePackages)) entries.push(sitePackages) - return entries - } - - const version = (() => { - try { - const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8') - const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im) - return match ? match[1].trim() : null - } catch { - return null - } - })() - if (version) { - const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages') - if (directoryExists(sitePackages)) entries.push(sitePackages) - } - return entries -} - function makeDashboardReadyFile() { const dir = path.join(app.getPath('userData'), 'backend-ready') fs.mkdirSync(dir, { recursive: true }) + return path.join(dir, `dashboard-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.json`) } @@ -1713,26 +1821,35 @@ function makeDashboardReadyFile() { // "Couldn't check for updates". Mirror findGitBash: PortableGit first, then // standard Git-for-Windows locations, then PATH. Cached after first probe. let _gitBinaryCache = null + function resolveGitBinary() { - if (_gitBinaryCache) return _gitBinaryCache + if (_gitBinaryCache) { + return _gitBinaryCache + } + if (!IS_WINDOWS) { _gitBinaryCache = findOnPath('git') || 'git' + return _gitBinaryCache } const localAppData = process.env.LOCALAPPDATA || '' const candidates = [] + if (localAppData) { candidates.push(path.join(localAppData, 'hermes', 'git', 'cmd', 'git.exe')) candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'git.exe')) } + candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'cmd', 'git.exe')) candidates.push(path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'cmd', 'git.exe')) + if (localAppData) { candidates.push(path.join(localAppData, 'Programs', 'Git', 'cmd', 'git.exe')) } _gitBinaryCache = candidates.find(fileExists) || findOnPath('git') || 'git' + return _gitBinaryCache } @@ -1741,13 +1858,17 @@ function resolveGitBinary() { // lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's // terminal. Check the common install locations first, then PATH. Cached. let _ghBinaryCache = null + function resolveGhBinary() { - if (_ghBinaryCache) return _ghBinaryCache + if (_ghBinaryCache) { + return _ghBinaryCache + } const candidates = [] if (IS_WINDOWS) { candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe')) + if (process.env.LOCALAPPDATA) { candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe')) } @@ -1757,6 +1878,7 @@ function resolveGhBinary() { } _ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh' + return _ghBinaryCache } @@ -1770,6 +1892,7 @@ function readDesktopUpdateConfig() { try { const parsed = JSON.parse(fs.readFileSync(DESKTOP_UPDATE_CONFIG_PATH, 'utf8')) const branch = typeof parsed?.branch === 'string' ? parsed.branch.trim() : '' + return { branch: branch || DEFAULT_UPDATE_BRANCH } } catch { return { branch: DEFAULT_UPDATE_BRANCH } @@ -1778,7 +1901,7 @@ function readDesktopUpdateConfig() { // Atomic file write: temp + rename (atomic on all platforms). Prevents // partial writes on crash/power loss that corrupt JSON config files. -function writeFileAtomic(targetPath, data, encoding) { +function writeFileAtomic(targetPath, data, encoding?: BufferEncoding) { const tmp = targetPath + '.tmp' fs.writeFileSync(tmp, data, encoding) fs.renameSync(tmp, targetPath) @@ -1803,7 +1926,10 @@ function readWindowState() { // getNormalBounds() keeps the pre-maximize size, so un-maximizing next session // lands back where the user actually sized the window. function persistWindowState() { - if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) return + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) { + return + } + try { const { x, y, width, height } = mainWindow.getNormalBounds() fs.mkdirSync(path.dirname(DESKTOP_WINDOW_STATE_PATH), { recursive: true }) @@ -1832,14 +1958,14 @@ function resolveUpdateRoot() { return candidates.find(c => directoryExists(path.join(c, '.git'))) || candidates[0] || ACTIVE_HERMES_ROOT } -function runGit(args, options = {}) { +function runGit(args, options: any = {}): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn( resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({ cwd: options.cwd, - env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' }, + env: { ...process.env, ...((options.env || {}) as any), GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] }) ) @@ -1865,12 +1991,14 @@ const firstLine = text => (text || '').split('\n').find(Boolean) || '' async function getOriginUrl(updateRoot) { const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot }) + return origin.code === 0 ? origin.stdout.trim() : '' } function emitUpdateProgress(payload) { const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() } rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`) + for (const window of BrowserWindow.getAllWindows()) { window.webContents.send('hermes:updates:progress', merged) } @@ -1890,15 +2018,18 @@ async function resolveHealedBranch(updateRoot, branch) { const originUrl = await getOriginUrl(updateRoot) const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin' const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot }) + if (probe.code !== 2) { return branch } rememberLog(`[updates] origin/${branch} is gone (merged?); falling back to main`) const config = readDesktopUpdateConfig() + if (config.branch !== 'main') { writeDesktopUpdateConfig({ ...config, branch: 'main' }) } + return 'main' } @@ -1906,6 +2037,7 @@ async function checkUpdates() { const updateRoot = resolveUpdateRoot() let { branch } = readDesktopUpdateConfig() const gitDir = path.join(updateRoot, '.git') + if (!directoryExists(gitDir)) { return { supported: false, @@ -1918,15 +2050,19 @@ async function checkUpdates() { branch = await resolveHealedBranch(updateRoot, branch) const originUrl = await getOriginUrl(updateRoot) + if (isOfficialSshRemote(originUrl)) { const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) + const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([ git(['rev-parse', 'HEAD']), runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }), git(['status', '--porcelain']), git(['rev-parse', '--abbrev-ref', 'HEAD']) ]) + const targetSha = firstLine(target.stdout).split(/\s+/)[0] || '' + if (target.code !== 0 || !targetSha) { return { supported: true, @@ -1937,6 +2073,7 @@ async function checkUpdates() { fetchedAt: Date.now() } } + return { supported: true, branch, @@ -1952,6 +2089,7 @@ async function checkUpdates() { } const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot }) + if (fetched.code !== 0) { return { supported: true, @@ -1964,6 +2102,7 @@ async function checkUpdates() { } const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) + const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr, mergeBaseStr] = await Promise.all([ git(['rev-parse', 'HEAD']), git(['rev-parse', `origin/${branch}`]), @@ -1977,6 +2116,7 @@ async function checkUpdates() { const isShallow = shallowStr === 'true' const hasMergeBase = Boolean(mergeBaseStr) + // Only enumerate the commit count when it is meaningful. On a shallow checkout // with no merge-base, `rev-list --count` walks the entire remote ancestry // (thousands of commits, see #51922) and resolveBehindCount discards the @@ -1992,6 +2132,7 @@ async function checkUpdates() { isShallow, hasMergeBase }) + const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : [] return { @@ -2011,6 +2152,7 @@ async function checkUpdates() { async function readCommitLog(cwd, branch) { const SEP = '\x1f' const REC = '\x1e' + const { stdout } = await runGit( ['log', `HEAD..origin/${branch}`, `--pretty=format:%H${SEP}%s${SEP}%an${SEP}%at${REC}`, '-n', '40'], { cwd } @@ -2022,6 +2164,7 @@ async function readCommitLog(cwd, branch) { .filter(Boolean) .map(line => { const [sha, summary, author, at] = line.split(SEP) + return { sha, summary, author, at: Number.parseInt(at, 10) * 1000 } }) } @@ -2048,11 +2191,14 @@ let isQuittingForHandoff = false function resolveUpdaterBinary() { const name = IS_WINDOWS ? 'hermes-setup.exe' : 'hermes-setup' const candidate = path.join(HERMES_HOME, name) + return fileExists(candidate) ? candidate : null } function repairMacUpdaterHelper(updater) { - if (!IS_MAC || !updater) return + if (!IS_MAC || !updater) { + return + } try { execFileSync('/usr/bin/xattr', ['-cr', updater], { stdio: 'ignore' }) @@ -2062,6 +2208,7 @@ function repairMacUpdaterHelper(updater) { try { execFileSync('/usr/bin/codesign', ['--verify', updater], { stdio: 'ignore' }) + return } catch { // Unsigned or invalid helper. Apply a local ad-hoc signature so Gatekeeper @@ -2090,10 +2237,15 @@ function venvHermesShimPath(updateRoot) { // this practically always succeeds (no mandatory locking), so it returns false // — correct, since the shim-contention brick is Windows-only. function isShimLocked(shimPath) { - if (!IS_WINDOWS) return false + if (!IS_WINDOWS) { + return false + } + let fd + try { fd = fs.openSync(shimPath, 'r+') + return false } catch (err) { // ENOENT ⇒ not there ⇒ nothing locking it. Anything else (EBUSY/EPERM/ @@ -2119,8 +2271,14 @@ function isShimLocked(shimPath) { // not a process-group leader — a POSIX negative-pgid kill would be meaningless // here anyway). POSIX teardown stays with the existing before-quit SIGTERM. function forceKillProcessTree(pid) { - if (!IS_WINDOWS) return - if (!Number.isInteger(pid) || pid <= 0) return + if (!IS_WINDOWS) { + return + } + + if (!Number.isInteger(pid) || pid <= 0) { + return + } + try { execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' })) } catch { @@ -2160,13 +2318,21 @@ async function releaseBackendLockForUpdate(updateRoot) { // `tag` only flavors the log lines. No-op off Windows (POSIX has no mandatory // locks — the before-quit SIGTERM + the cleanup script's own PID-wait suffice). async function releaseBackendLock(updateRoot, tag) { - if (!IS_WINDOWS) return { unlocked: true } + if (!IS_WINDOWS) { + return { unlocked: true } + } // Collect every backend PID the desktop owns: primary window backend + pool. const pids = [] - if (hermesProcess && Number.isInteger(hermesProcess.pid)) pids.push(hermesProcess.pid) + + if (hermesProcess && Number.isInteger(hermesProcess.pid)) { + pids.push(hermesProcess.pid) + } + for (const entry of backendPool.values()) { - if (entry.process && Number.isInteger(entry.process.pid)) pids.push(entry.process.pid) + if (entry.process && Number.isInteger(entry.process.pid)) { + pids.push(entry.process.pid) + } } // Graceful first (lets Python flush), then tree-kill to catch grandchildren. @@ -2177,27 +2343,45 @@ async function releaseBackendLock(updateRoot, tag) { void 0 } } + stopAllPoolBackends() - for (const pid of pids) forceKillProcessTree(pid) + + for (const pid of pids) { + forceKillProcessTree(pid) + } const shim = venvHermesShimPath(updateRoot) const deadlineMs = Date.now() + 15000 + while (Date.now() < deadlineMs) { if (!isShimLocked(shim)) { rememberLog(`[${tag}] venv shim unlocked; safe to proceed`) + return { unlocked: true } } + // A supervised backend can respawn between kill and check (grandchildren, // pool entries registered mid-teardown). Re-collect and re-kill each pass // instead of trusting the initial sweep. const stragglers = [] - if (hermesProcess && Number.isInteger(hermesProcess.pid)) stragglers.push(hermesProcess.pid) - for (const entry of backendPool.values()) { - if (entry.process && Number.isInteger(entry.process.pid)) stragglers.push(entry.process.pid) + + if (hermesProcess && Number.isInteger(hermesProcess.pid)) { + stragglers.push(hermesProcess.pid) } - for (const pid of stragglers) forceKillProcessTree(pid) + + for (const entry of backendPool.values()) { + if (entry.process && Number.isInteger(entry.process.pid)) { + stragglers.push(entry.process.pid) + } + } + + for (const pid of stragglers) { + forceKillProcessTree(pid) + } + await new Promise(r => setTimeout(r, 300)) } + // Do NOT proceed past a held lock: handing off to the updater while another // process (a second desktop window, a user terminal, an unkillable child) // still maps the venv's files guarantees a half-updated venv — the updater's @@ -2205,7 +2389,10 @@ async function releaseBackendLock(updateRoot, tag) { // imports broken (the July 2026 brotlicffi/_sodium.pyd incidents). Failing // the update loudly and keeping the app running is strictly better than a // bricked install that needs manual venv surgery. - rememberLog(`[${tag}] venv shim still locked after 15s; aborting hand-off (something outside this app holds the venv)`) + rememberLog( + `[${tag}] venv shim still locked after 15s; aborting hand-off (something outside this app holds the venv)` + ) + return { unlocked: false } } @@ -2223,10 +2410,12 @@ async function applyUpdates(opts = {}) { if (updateInFlight) { throw new Error('An update is already in progress.') } + updateInFlight = true try { const updater = resolveUpdaterBinary() + if (!updater && !IS_WINDOWS) { // macOS/Linux drag-install: no staged Tauri hermes-setup. Unlike Windows // (where a venv-shim file lock forces the quit→hand-off→rebuild dance), @@ -2236,6 +2425,7 @@ async function applyUpdates(opts = {}) { // with the freshly built one and relaunch. return await applyUpdatesPosixInApp(opts) } + if (!updater) { // No staged updater binary — this is a CLI-installed user (they ran // `hermes desktop`, never the Tauri installer that self-copies @@ -2248,18 +2438,25 @@ async function applyUpdates(opts = {}) { // checkouts, keep it bare for main so the card stays clean. const updateRoot = resolveUpdateRoot() let command = 'hermes update' + try { const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) const current = (head.stdout || '').trim() + if (head.code === 0 && current && current !== 'HEAD') { const branch = await resolveHealedBranch(updateRoot, current) - if (branch !== 'main') command = `hermes update --branch ${branch}` + + if (branch !== 'main') { + command = `hermes update --branch ${branch}` + } } } catch { // Best-effort: fall back to bare `hermes update` if branch detection fails. } + rememberLog(`[updates] no staged updater; surfacing manual \`${command}\` for CLI install at ${updateRoot}`) emitUpdateProgress({ stage: 'manual', message: command, percent: null }) + return { ok: true, manual: true, command, hermesRoot: updateRoot } } @@ -2276,9 +2473,11 @@ async function applyUpdates(opts = {}) { const branch = await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) const updaterArgs = ['--update', '--branch', branch] const targetApp = IS_MAC ? runningAppBundle() : null + if (targetApp) { updaterArgs.push('--target-app', targetApp) } + const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') // Stop our own backend(s) and wait for the venv shim to unlock BEFORE we @@ -2286,6 +2485,7 @@ async function applyUpdates(opts = {}) { // hermes.exe (held by the backend child / its grandchildren) and the update // bricks. See releaseBackendLockForUpdate for the full failure analysis. const lock = await releaseBackendLockForUpdate(updateRoot) + if (!lock.unlocked) { // Something OUTSIDE this app holds the venv (a second window, a user // terminal running hermes, an unkillable child). Handing off anyway @@ -2295,8 +2495,10 @@ async function applyUpdates(opts = {}) { const message = 'Update aborted: another process is holding the Hermes install open ' + '(a second Hermes window or a terminal running hermes?). Close it and retry.' + emitUpdateProgress({ stage: 'error', message, percent: null }) startHermes().catch(() => {}) + return { ok: false, error: message } } @@ -2313,6 +2515,7 @@ async function applyUpdates(opts = {}) { stdio: 'ignore', windowsHide: false }) + child.unref() // Write the update-in-progress marker IMMEDIATELY — before the 2.5s @@ -2345,19 +2548,27 @@ async function applyUpdates(opts = {}) { } async function handOffWindowsBootstrapRecovery(reason) { - if (!IS_WINDOWS || !IS_PACKAGED) return false + if (!IS_WINDOWS || !IS_PACKAGED) { + return false + } const updater = resolveUpdaterBinary() - if (!updater) return false + + if (!updater) { + return false + } const updateRoot = resolveUpdateRoot() const { branch: configuredBranch } = readDesktopUpdateConfig() + const branch = directoryExists(path.join(updateRoot, '.git')) ? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) : configuredBranch || DEFAULT_UPDATE_BRANCH + const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python') + // Choose the gentle in-place --update when ANY real-install signal is present, // not just the `hermes.exe` console-script shim. That shim is generated at the // END of venv setup and is absent in exactly the interrupted/quarantined states @@ -2366,7 +2577,8 @@ async function handOffWindowsBootstrapRecovery(reason) { // and the bootstrap-complete marker are present earlier and are better signals. const haveRealInstall = fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')) - const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] + + const updaterArgs = chooseUpdaterArgs(haveRealInstall, branch) await releaseBackendLockForUpdate(updateRoot) @@ -2381,6 +2593,7 @@ async function handOffWindowsBootstrapRecovery(reason) { stdio: 'ignore', windowsHide: false }) + child.unref() // Same marker pre-write as applyUpdates — see comment there. The recovery @@ -2408,14 +2621,19 @@ async function handOffWindowsBootstrapRecovery(reason) { // the install we're updating, fall back to `hermes` on PATH. function resolveHermesCliBinary(updateRoot) { const venvHermes = path.join(updateRoot, 'venv', 'bin', 'hermes') - if (fileExists(venvHermes)) return venvHermes + + if (fileExists(venvHermes)) { + return venvHermes + } + return findOnPath('hermes') || null } // Spawn a command and stream each output line to the update progress channel. -function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { +function runStreamedUpdate(command, args, { cwd, env, stage }: any = {}) { return new Promise(resolve => { let child + try { child = spawn( command, @@ -2428,14 +2646,20 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { ) } catch (err) { resolve({ code: 1, error: err.message }) + return } + const emitLines = chunk => { for (const line of chunk.toString().split('\n')) { const trimmed = line.trim() - if (trimmed) emitUpdateProgress({ stage, message: trimmed, percent: null }) + + if (trimmed) { + emitUpdateProgress({ stage, message: trimmed, percent: null }) + } } } + child.stdout.on('data', emitLines) child.stderr.on('data', emitLines) child.once('error', err => resolve({ code: 1, error: err.message })) @@ -2446,9 +2670,16 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { // The running app's .app bundle (packaged macOS): execPath is // <App>.app/Contents/MacOS/<exe>; climb three levels to the bundle root. function runningAppBundle() { - if (!IS_MAC) return null + if (!IS_MAC) { + return null + } + let dir = path.dirname(app.getPath('exe')) // .../Contents/MacOS - for (let i = 0; i < 2; i++) dir = path.dirname(dir) // -> .../X.app + + for (let i = 0; i < 2; i++) { + dir = path.dirname(dir) + } // -> .../X.app + return dir.endsWith('.app') ? dir : null } @@ -2460,18 +2691,20 @@ function shellQuote(value) { // (`hermes desktop --build-only`), then atomically swap the running .app bundle // with the freshly built one and relaunch. Degrades to "backend updated, // restart to load the new GUI" if the swap can't be performed. -async function applyUpdatesPosixInApp() { +async function applyUpdatesPosixInApp(opts: any) { const updateRoot = resolveUpdateRoot() const hermes = resolveHermesCliBinary(updateRoot) + if (!hermes) { emitUpdateProgress({ stage: 'manual', message: 'hermes update', percent: null }) + return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot } } // Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s // npm build can find them on a machine with no system Node. Windows portable // Node lives directly under %LOCALAPPDATA%\hermes\node, not node\bin. - const env = { + const env: Record<string, string> = { HERMES_HOME, PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin')) } @@ -2488,14 +2721,17 @@ async function applyUpdatesPosixInApp() { // the update reaper. _kill_stale_dashboard_processes accepts a comma-separated // list (a single int still parses for back-compat). const desktopChildPids = [] + if (hermesProcess && Number.isInteger(hermesProcess.pid)) { desktopChildPids.push(hermesProcess.pid) } + for (const entry of backendPool.values()) { if (entry.process && Number.isInteger(entry.process.pid)) { desktopChildPids.push(entry.process.pid) } } + if (desktopChildPids.length) { env.HERMES_DESKTOP_CHILD_PID = desktopChildPids.join(',') } @@ -2503,9 +2739,11 @@ async function applyUpdatesPosixInApp() { // Branch-pin so a non-main checkout doesn't get switched to main (and self-heal // to main when the pinned branch no longer exists on origin). let branchArgs = [] + try { const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) const current = (head.stdout || '').trim() + if (head.code === 0 && current && current !== 'HEAD') { branchArgs = ['--branch', await resolveHealedBranch(updateRoot, current)] } @@ -2514,17 +2752,21 @@ async function applyUpdatesPosixInApp() { } emitUpdateProgress({ stage: 'update', message: 'Updating Hermes (git + dependencies)…', percent: 10 }) - const updated = await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], { + + const updated = (await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], { cwd: updateRoot, env, stage: 'update' - }) + })) as any + if (updated.code !== 0) { emitUpdateProgress({ stage: 'error', message: 'hermes update failed.', error: updated.error || 'update-failed' }) + return { ok: false, error: 'hermes update failed' } } emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 }) + // Retry-once: a first rebuild can fail on a still-settling tree or a // self-healed (network-blocked) Electron download; a second run builds clean // off the healed dist so we reach the swap+relaunch below instead of bailing. @@ -2532,14 +2774,17 @@ async function applyUpdatesPosixInApp() { if (attempt > 0) { emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 }) } + return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' }) }) + if (rebuilt.code !== 0) { emitUpdateProgress({ stage: 'error', message: 'Backend updated, but the desktop rebuild failed. Restart Hermes to retry.', error: rebuilt.error || 'rebuild-failed' }) + return { ok: false, backendUpdated: true, error: 'desktop rebuild failed' } } @@ -2547,7 +2792,7 @@ async function applyUpdatesPosixInApp() { // rebuilds the unpacked app in place under apps/desktop/release/<plat>-unpacked. // We can only HONESTLY relaunch into the new GUI when the *running* binary IS // that rebuilt one — i.e. execPath lives under release/<plat>-unpacked. The - // outcome is decided by three signals (see update-relaunch.cjs): + // outcome is decided by three signals (see update-relaunch.ts): // // underUnpacked + sandboxOk → 'relaunch': detached watcher re-execs us in // place (mirrors the macOS handoff). Without it the update succeeds but @@ -2569,8 +2814,10 @@ async function applyUpdatesPosixInApp() { const preflight = underUnpacked ? sandboxPreflight(unpackedDir, p => fs.statSync(p)) : { ok: false, reason: 'not-under-unpacked', path: null } + const sandboxFallback = sandboxFallbackFromEnv(process.env, process.argv.slice(1)) const sandboxOk = preflight.ok || sandboxFallback + if (underUnpacked && !preflight.ok) { rememberLog( `[updates] sandbox preflight: not launchable (${preflight.reason}) at ${preflight.path}; ` + @@ -2588,6 +2835,7 @@ async function applyUpdatesPosixInApp() { // relaunched instance comes up with default context instead of the user's. const relaunchArgs = collectRelaunchArgs(process.argv.slice(1)) const relaunchEnv = collectRelaunchEnv(process.env) + const relaunchScript = buildRelaunchScript({ pid: process.pid, execPath: process.execPath, @@ -2595,7 +2843,9 @@ async function applyUpdatesPosixInApp() { env: relaunchEnv, cwd: process.cwd() }) + const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`) + try { fs.writeFileSync(scriptPath, relaunchScript, { mode: 0o755 }) const child = spawn('/bin/bash', [scriptPath], { detached: true, stdio: 'ignore' }) @@ -2606,9 +2856,11 @@ async function applyUpdatesPosixInApp() { ) isQuittingForHandoff = true setTimeout(() => app.quit(), UPDATE_HANDOFF_DWELL_MS) + return { ok: true, handedOff: true } } catch (err) { rememberLog(`[updates] linux relaunch failed: ${err.message}; falling back to manual restart`) + return { ok: true, backendUpdated: true, @@ -2631,6 +2883,7 @@ async function applyUpdatesPosixInApp() { `[updates] gui/backend skew: execPath ${process.execPath} not under release/*-unpacked; ` + 'backend updated, GUI package unchanged (AppImage/.deb/.rpm/dev/unresolved)' ) + return { ok: true, backendUpdated: true, guiUpdated: false, guiSkew: true } } @@ -2640,6 +2893,7 @@ async function applyUpdatesPosixInApp() { `[updates] sandbox not launchable (${preflight.reason}); skipping auto-relaunch, ` + 'returning manual-restart so the user keeps a working window' ) + return { ok: true, backendUpdated: true, @@ -2656,6 +2910,7 @@ async function applyUpdatesPosixInApp() { path.join(updateRoot, 'apps', 'desktop', 'release', 'mac-arm64', 'Hermes.app'), path.join(updateRoot, 'apps', 'desktop', 'release', 'mac', 'Hermes.app') ].find(directoryExists) + const targetApp = runningAppBundle() // No bundle to swap (dev run, Linux AppImage, or unresolved paths): the @@ -2666,6 +2921,7 @@ async function applyUpdatesPosixInApp() { message: 'Backend updated. Restart Hermes to load the new version.', percent: 100 }) + return { ok: true, backendUpdated: true, rebuiltApp: rebuiltApp || null } } @@ -2693,7 +2949,9 @@ fi /usr/bin/xattr -dr com.apple.quarantine "$DST" 2>/dev/null || true /usr/bin/open "$DST" ` + const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`) + try { fs.writeFileSync(scriptPath, swapScript, { mode: 0o755 }) } catch (err) { @@ -2703,6 +2961,7 @@ fi percent: 100 }) rememberLog(`[updates] could not write swap script: ${err.message}; rebuilt app at ${rebuiltApp}`) + return { ok: true, backendUpdated: true, rebuiltApp } } @@ -2712,6 +2971,7 @@ fi isQuittingForHandoff = true setTimeout(() => app.quit(), 600) + return { ok: true, handedOff: true, rebuiltApp, targetApp } } @@ -2748,6 +3008,7 @@ function readBootstrapMarker() { // "already installed" off the filesystem alone, not just the marker. function isActiveRuntimeUsable() { const venvPython = getVenvPython(VENV_ROOT) + return ( isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(venvPython) && @@ -2761,9 +3022,19 @@ function isActiveRuntimeUsable() { function isBootstrapComplete() { const marker = readBootstrapMarker() - if (!marker || typeof marker !== 'object') return false - if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false - if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false + + if (!marker || typeof marker !== 'object') { + return false + } + + if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) { + return false + } + + if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) { + return false + } + // We DELIBERATELY do NOT verify that the checkout is currently at the // pinned commit -- users update via the in-app update path or `hermes // update`, which moves HEAD legitimately. The marker just attests "we @@ -2776,6 +3047,7 @@ function isBootstrapComplete() { function writeBootstrapMarker(payload) { fs.mkdirSync(path.dirname(BOOTSTRAP_COMPLETE_MARKER), { recursive: true }) + const merged = { schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION, pinnedCommit: payload.pinnedCommit || null, @@ -2783,16 +3055,24 @@ function writeBootstrapMarker(payload) { completedAt: new Date().toISOString(), desktopVersion: app.getVersion() } + writeFileAtomic(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8') + return merged } function resolveWebDist() { const override = process.env.HERMES_DESKTOP_WEB_DIST - if (override && directoryExists(path.resolve(override))) return path.resolve(override) + + if (override && directoryExists(path.resolve(override))) { + return path.resolve(override) + } const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist') - if (directoryExists(unpackedDist)) return unpackedDist + + if (directoryExists(unpackedDist)) { + return unpackedDist + } // Final fallback: APP_ROOT/dist. When packaged with asar:true this lives // INSIDE app.asar — not a servable filesystem directory — so the embedded @@ -2801,6 +3081,7 @@ function resolveWebDist() { // unpackedDist above resolves). If we still land here while packaged, log it // so the cause isn't silent. const fallback = path.join(APP_ROOT, 'dist') + if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !directoryExists(fallback)) { rememberLog( `[web-dist] dashboard frontend dir resolved to an asar-internal path that ` + @@ -2808,13 +3089,18 @@ function resolveWebDist() { `Ensure dist/** is unpacked (asarUnpack) or set HERMES_DESKTOP_WEB_DIST.` ) } + return fallback } function resolveRendererIndex() { const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')] const found = candidates.find(fileExists) - if (found) return found + + if (found) { + return found + } + // Nothing on disk. A packaged build with no renderer bundle blank-pages with // a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause // and the fix before Electron loads the missing file. @@ -2823,6 +3109,7 @@ function resolveRendererIndex() { `renderer bundle. Tried: ${candidates.join(', ')}. ` + `Rebuild with: hermes desktop --force-build` ) + return candidates[0] } @@ -2859,14 +3146,19 @@ function resolveHermesCwd() { ] for (const candidate of candidates) { - if (!candidate) continue + if (!candidate) { + continue + } + const resolved = path.resolve(String(candidate)) if (isPackagedInstallPath(resolved)) { continue } - if (directoryExists(resolved)) return resolved + if (directoryExists(resolved)) { + return resolved + } } return app.getPath('home') @@ -2934,9 +3226,12 @@ function writeDefaultProjectDir(dir) { } } -function createPythonBackend(root, label, backendArgs, options = {}) { +function createPythonBackend(root, label, backendArgs, options: any = {}) { const python = findPythonForRoot(root) - if (!python) return null + + if (!python) { + return null + } const venvRoot = path.join(root, 'venv') const venvPython = getVenvPython(venvRoot) @@ -2986,9 +3281,13 @@ function resolveHermesBackend(backendArgs) { // 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer // checkout. Honour it as-is (no bootstrap; the user is driving). const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT) + if (overrideRoot && isHermesSourceRoot(overrideRoot)) { const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, backendArgs) - if (backend) return backend + + if (backend) { + return backend + } } // 2. Development source -- when running `npm run dev` from a checkout, the @@ -2997,7 +3296,10 @@ function resolveHermesBackend(backendArgs) { // (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.) if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) { const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, backendArgs) - if (backend) return backend + + if (backend) { + return backend + } } // 3. Bootstrap-complete ACTIVE_HERMES_ROOT -- the canonical install at @@ -3021,6 +3323,7 @@ function resolveHermesBackend(backendArgs) { if (hermesOverride) { const resolvedOverride = findOnPath(hermesOverride) + if (resolvedOverride) { hermesCommand = resolvedOverride } else if (!isWindowsBinaryPathInWsl(hermesOverride, { isWsl: IS_WSL })) { @@ -3041,6 +3344,7 @@ function resolveHermesBackend(backendArgs) { if (hermesCommand) { const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) + if (unwrapped) { return unwrapped } @@ -3050,9 +3354,10 @@ function resolveHermesBackend(backendArgs) { // entry-point pointing at a deleted interpreter) still resolves // via findOnPath but explodes on spawn -- the user then sees a // dead backend instead of the first-launch installer. The cheap - // `--version` probe (see backend-probes.cjs) catches that case + // `--version` probe (see backend-probes.ts) catches that case // and lets the resolver fall through to step 6 / bootstrap. const shellForProbe = isCommandScript(hermesCommand) + if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) { return ( unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || { @@ -3066,6 +3371,7 @@ function resolveHermesBackend(backendArgs) { } ) } + rememberLog( `Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.` ) @@ -3076,6 +3382,7 @@ function resolveHermesBackend(backendArgs) { // Same rationale as #4 -- the user installed this; we use it but don't // take ownership. const python = findSystemPython() + if (python) { // Same smoke-test rationale as step 4: a system Python in the // SUPPORTED_VERSIONS range can be registered (PEP 514) without @@ -3096,6 +3403,7 @@ function resolveHermesBackend(backendArgs) { shell: false } } + rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) } @@ -3128,6 +3436,7 @@ function resolveHermesBackend(backendArgs) { async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) + return backend } @@ -3144,9 +3453,10 @@ async function ensureRuntime(backend) { rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap') if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) { - const handoffError = new Error( + const handoffError: Error & { isBootstrapFailure?: boolean; bootstrapHandedOff?: boolean } = new Error( 'Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.' ) + handoffError.isBootstrapFailure = true handoffError.bootstrapHandedOff = true bootstrapFailure = handoffError @@ -3188,6 +3498,7 @@ async function ensureRuntime(backend) { } catch { void 0 } + try { broadcastBootstrapEvent(ev) } catch { @@ -3200,7 +3511,7 @@ async function ensureRuntime(backend) { bootstrapAbortController = null if (bootstrapResult.cancelled) { - const cancelledError = new Error('Hermes install was cancelled.') + const cancelledError = new Error('Hermes install was cancelled.') as any cancelledError.isBootstrapFailure = true cancelledError.bootstrapCancelled = true bootstrapFailure = cancelledError @@ -3212,7 +3523,8 @@ async function ensureRuntime(backend) { `Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` + `${bootstrapResult.error || 'unknown error'}. ` + `Check ${path.join(HERMES_HOME, 'logs', 'desktop.log')} for the full transcript.` - ) + ) as any + bootstrapError.isBootstrapFailure = true bootstrapError.failedStage = bootstrapResult.failedStage || null // Latch the failure so subsequent startHermes() calls return this @@ -3223,6 +3535,7 @@ async function ensureRuntime(backend) { } rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.') + // Re-resolve now that the install exists. The new resolution lands in // step 3 (bootstrap-complete marker) and we recurse to wire venvPython. return ensureRuntime(resolveHermesBackend(backend.args)) @@ -3257,6 +3570,7 @@ async function ensureRuntime(backend) { } const venvPython = getVenvPython(VENV_ROOT) + if (!fileExists(venvPython)) { // No venv at the expected location AND no bootstrap-needed sentinel // means we have a half-installed checkout: .git exists, source files @@ -3279,18 +3593,46 @@ async function ensureRuntime(backend) { running: true, error: null }) + return backend } -function fetchJson(url, token, options = {}) { +// Assemble a single-file multipart/form-data body (FastAPI `UploadFile` +// endpoints, e.g. kanban attachments). Hand-rolled because node's http has no +// FormData and the payload is one file — a dependency would be overkill. +function multipartBody(upload) { + const boundary = `----hermes-${crypto.randomBytes(12).toString('hex')}` + const filename = String(upload.filename || 'file').replace(/["\r\n]/g, '_') + + const body = Buffer.concat([ + Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + + `Content-Type: ${upload.contentType || 'application/octet-stream'}\r\n\r\n` + ), + Buffer.from(upload.bytes), + Buffer.from(`\r\n--${boundary}--\r\n`) + ]) + + return { body, contentType: `multipart/form-data; boundary=${boundary}` } +} + +function fetchJson(url, token, options: any = {}) { return new Promise((resolve, reject) => { - const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) + const { body, contentType } = options.upload + ? multipartBody(options.upload) + : { + body: options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)), + contentType: 'application/json' + } + const parsed = new URL(url) const client = parsed.protocol === 'https:' ? https : http const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + return } @@ -3299,7 +3641,7 @@ function fetchJson(url, token, options = {}) { { method: options.method || 'GET', headers: { - 'Content-Type': 'application/json', + 'Content-Type': contentType, 'X-Hermes-Session-Token': token, ...(body ? { 'Content-Length': String(body.length) } : {}) } @@ -3310,20 +3652,26 @@ function fetchJson(url, token, options = {}) { res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8') + if ((res.statusCode || 500) >= 400) { reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) + return } + if (!text) { resolve(null) + return } + // A 2xx response whose body is HTML means the request fell through // to the SPA index.html (e.g. an unregistered /api path). JSON.parse // would throw an opaque `Unexpected token '<'` here, so surface a // clear diagnostic with the offending URL instead. const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || '') + if (looksHtml || contentType.includes('text/html')) { reject( new Error( @@ -3331,8 +3679,10 @@ function fetchJson(url, token, options = {}) { 'The endpoint is likely missing on the Hermes backend.' ) ) + return } + try { resolve(JSON.parse(text)) } catch { @@ -3346,12 +3696,16 @@ function fetchJson(url, token, options = {}) { req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - if (body) req.write(body) + + if (body) { + req.write(body) + } + req.end() }) } -function fetchPublicJson(url, options = {}) { +function fetchPublicJson(url, options: any = {}) { // Credential-free JSON GET/POST for public gateway endpoints // (``/api/status``, ``/api/auth/providers``). Unlike ``fetchJson`` it sends // NO ``X-Hermes-Session-Token`` header — used by the auth-mode probe before @@ -3360,17 +3714,21 @@ function fetchPublicJson(url, options = {}) { return new Promise((resolve, reject) => { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) let parsed + try { parsed = new URL(url) } catch (error) { reject(new Error(`Invalid URL: ${error.message}`)) + return } + const client = parsed.protocol === 'https:' ? https : http const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + return } @@ -3388,16 +3746,22 @@ function fetchPublicJson(url, options = {}) { res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8') + if ((res.statusCode || 500) >= 400) { reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) + return } + if (!text) { resolve(null) + return } + const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || '') + if (looksHtml || contentType.includes('text/html')) { reject( new Error( @@ -3405,8 +3769,10 @@ function fetchPublicJson(url, options = {}) { 'The endpoint is likely missing on the Hermes backend.' ) ) + return } + try { resolve(JSON.parse(text)) } catch { @@ -3420,7 +3786,11 @@ function fetchPublicJson(url, options = {}) { req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - if (body) req.write(body) + + if (body) { + req.write(body) + } + req.end() }) } @@ -3436,12 +3806,31 @@ function extensionForMimeType(mimeType) { .split(';')[0] .trim() .toLowerCase() - if (type === 'image/png') return '.png' - if (type === 'image/jpeg') return '.jpg' - if (type === 'image/gif') return '.gif' - if (type === 'image/webp') return '.webp' - if (type === 'image/bmp') return '.bmp' - if (type === 'image/svg+xml') return '.svg' + + if (type === 'image/png') { + return '.png' + } + + if (type === 'image/jpeg') { + return '.jpg' + } + + if (type === 'image/gif') { + return '.gif' + } + + if (type === 'image/webp') { + return '.webp' + } + + if (type === 'image/bmp') { + return '.bmp' + } + + if (type === 'image/svg+xml') { + return '.svg' + } + return '' } @@ -3449,6 +3838,7 @@ function filenameFromUrl(rawUrl, fallback = 'image') { try { const parsed = new URL(rawUrl) const base = path.basename(decodeURIComponent(parsed.pathname || '')) + return base && base.includes('.') ? base : fallback } catch { return fallback @@ -3462,12 +3852,15 @@ const TITLE_CACHE_LIMIT = 500 const TITLE_BYTE_BUDGET = 96 * 1024 const TITLE_TIMEOUT_MS = 5000 const TITLE_MAX_REDIRECTS = 3 + // Browser-shaped UA — many bot-walled sites (GetYourGuide, Cloudflare-protected // pages) refuse anything that doesn't look like a real Chrome. const TITLE_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36' + const TITLE_ERROR_RE = /\b(access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i + const HTML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" } // Tier-2 renderer fallback config. Only invoked when curl came back empty or @@ -3475,6 +3868,7 @@ const HTML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: const RENDER_TITLE_MAX_CONCURRENT = 2 const RENDER_TITLE_TIMEOUT_MS = 8000 const RENDER_TITLE_GRACE_MS = 700 + // Resource types we cancel before the network even fires — keeps the hidden // renderer fast and cuts third-party tracking noise. const RENDER_TITLE_BLOCKED_RESOURCES = new Set([ @@ -3494,7 +3888,10 @@ const renderTitleQueue = [] function canonicalTitleCacheKey(rawUrl) { const value = String(rawUrl || '').trim() - if (!value) return '' + + if (!value) { + return '' + } try { const url = new URL(value) @@ -3508,7 +3905,10 @@ function canonicalTitleCacheKey(rawUrl) { } function cacheTitle(key, title) { - if (titleCache.size >= TITLE_CACHE_LIMIT) titleCache.delete(titleCache.keys().next().value) + if (titleCache.size >= TITLE_CACHE_LIMIT) { + titleCache.delete(titleCache.keys().next().value) + } + titleCache.set(key, title) } @@ -3521,13 +3921,17 @@ function decodeHtmlEntities(value) { function parseHtmlTitle(html) { const raw = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] + return raw ? decodeHtmlEntities(raw).replace(/\s+/g, ' ').trim() : '' } -function fetchHtmlTitleWithCurl(rawUrl) { +function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> { return new Promise(resolve => { const url = String(rawUrl || '').trim() - if (!url) return resolve('') + + if (!url) { + return resolve('') + } const args = [ '--silent', @@ -3550,12 +3954,16 @@ function fetchHtmlTitleWithCurl(rawUrl) { '--raw', url ] + const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] })) const chunks = [] let bytes = 0 child.stdout.on('data', chunk => { - if (bytes >= TITLE_BYTE_BUDGET) return + if (bytes >= TITLE_BYTE_BUDGET) { + return + } + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) const remaining = TITLE_BYTE_BUDGET - bytes const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer @@ -3565,19 +3973,26 @@ function fetchHtmlTitleWithCurl(rawUrl) { child.on('error', () => resolve('')) child.on('close', () => { - if (!chunks.length) return resolve('') + if (!chunks.length) { + return resolve('') + } + resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8'))) }) }) } function getLinkTitleSession() { - if (linkTitleSession || !app.isReady()) return linkTitleSession + if (linkTitleSession || !app.isReady()) { + return linkTitleSession + } + linkTitleSession = session.fromPartition('hermes:link-titles', { cache: false }) linkTitleSession.webRequest.onBeforeRequest((details, callback) => { callback({ cancel: RENDER_TITLE_BLOCKED_RESOURCES.has(details.resourceType) }) }) guardLinkTitleSession(linkTitleSession) + return linkTitleSession } @@ -3595,10 +4010,15 @@ function dequeueRenderTitle() { function runRenderTitleJob(rawUrl) { return new Promise(resolve => { - if (!app.isReady()) return resolve('') + if (!app.isReady()) { + return resolve('') + } const partitionSession = getLinkTitleSession() - if (!partitionSession) return resolve('') + + if (!partitionSession) { + return resolve('') + } let settled = false let window = null @@ -3606,16 +4026,30 @@ function runRenderTitleJob(rawUrl) { let graceTimer = null const finish = title => { - if (settled) return + if (settled) { + return + } + settled = true - if (hardTimer) clearTimeout(hardTimer) - if (graceTimer) clearTimeout(graceTimer) + + if (hardTimer) { + clearTimeout(hardTimer) + } + + if (graceTimer) { + clearTimeout(graceTimer) + } + const value = (title || '').replace(/\s+/g, ' ').trim() + try { - if (window && !window.isDestroyed()) window.destroy() + if (window && !window.isDestroyed()) { + window.destroy() + } } catch { // BrowserWindow may already be torn down; ignore. } + resolve(value) } @@ -3626,8 +4060,12 @@ function runRenderTitleJob(rawUrl) { } const finishWithTitle = () => finish(readLinkTitleWindowTitle(window)) + const scheduleGrace = () => { - if (graceTimer) clearTimeout(graceTimer) + if (graceTimer) { + clearTimeout(graceTimer) + } + graceTimer = setTimeout(finishWithTitle, RENDER_TITLE_GRACE_MS) } @@ -3637,7 +4075,9 @@ function runRenderTitleJob(rawUrl) { window.webContents.on('page-title-updated', scheduleGrace) window.webContents.on('did-finish-load', scheduleGrace) window.webContents.on('did-fail-load', (_event, _code, _desc, _validatedURL, isMainFrame) => { - if (isMainFrame) finish('') + if (isMainFrame) { + finish('') + } }) window @@ -3649,7 +4089,7 @@ function runRenderTitleJob(rawUrl) { }) } -function fetchHtmlTitleWithRenderer(rawUrl) { +function fetchHtmlTitleWithRenderer(rawUrl: string): Promise<string> { return new Promise(resolve => { renderTitleQueue.push({ resolve, url: rawUrl }) dequeueRenderTitle() @@ -3658,14 +4098,25 @@ function fetchHtmlTitleWithRenderer(rawUrl) { // Strips known error/captcha titles (e.g. "GetYourGuide – Error", "Just a // moment...") so they don't get cached as the resolved title. -const usableTitle = value => (value && !TITLE_ERROR_RE.test(value) ? value : '') +function usableTitle(value: string): string { + return value && !TITLE_ERROR_RE.test(value) ? value : '' +} function fetchLinkTitle(rawUrl) { const url = String(rawUrl || '').trim() const key = canonicalTitleCacheKey(url) - if (!key) return Promise.resolve('') - if (titleCache.has(key)) return Promise.resolve(titleCache.get(key)) - if (titleInflight.has(key)) return titleInflight.get(key) + + if (!key) { + return Promise.resolve('') + } + + if (titleCache.has(key)) { + return Promise.resolve(titleCache.get(key)) + } + + if (titleInflight.has(key)) { + return titleInflight.get(key) + } const pending = fetchHtmlTitleWithCurl(url) .catch(() => '') @@ -3676,38 +4127,53 @@ function fetchLinkTitle(rawUrl) { .then(clean => { cacheTitle(key, clean) titleInflight.delete(key) + return clean }) titleInflight.set(key, pending) + return pending } async function resourceBufferFromUrl(rawUrl) { - if (!rawUrl) throw new Error('Missing URL') + if (!rawUrl) { + throw new Error('Missing URL') + } + if (rawUrl.startsWith('data:')) { const match = rawUrl.match(/^data:([^;,]+)?(;base64)?,(.*)$/s) - if (!match) throw new Error('Invalid data URL') + + if (!match) { + throw new Error('Invalid data URL') + } + const mimeType = match[1] || 'application/octet-stream' const encoded = match[3] || '' const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8') + return { buffer, mimeType } } + if (/^file:/i.test(rawUrl)) { const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' }) const buffer = await fs.promises.readFile(resolvedPath) + return { buffer, mimeType: mimeTypeForPath(resolvedPath) } } const parsed = new URL(rawUrl) const client = parsed.protocol === 'https:' ? https : http + return new Promise((resolve, reject) => { const req = client.get(parsed, res => { if ((res.statusCode || 500) >= 400) { reject(new Error(`Failed to fetch ${rawUrl}: ${res.statusCode}`)) res.resume() + return } + const chunks = [] res.on('error', reject) res.on('data', chunk => chunks.push(chunk)) @@ -3718,26 +4184,37 @@ async function resourceBufferFromUrl(rawUrl) { }) }) }) + req.on('error', reject) }) } async function copyImageFromUrl(rawUrl) { - const { buffer } = await resourceBufferFromUrl(rawUrl) + const { buffer } = (await resourceBufferFromUrl(rawUrl)) as any const image = nativeImage.createFromBuffer(buffer) - if (image.isEmpty()) throw new Error('Could not read image') + + if (image.isEmpty()) { + throw new Error('Could not read image') + } + clipboard.writeImage(image) } async function saveImageFromUrl(rawUrl) { - const { buffer, mimeType } = await resourceBufferFromUrl(rawUrl) + const { buffer, mimeType } = (await resourceBufferFromUrl(rawUrl)) as any const fallbackName = filenameFromUrl(rawUrl, `image${extensionForMimeType(mimeType) || '.png'}`) + const result = await dialog.showSaveDialog(mainWindow, { title: 'Save Image', defaultPath: fallbackName }) - if (result.canceled || !result.filePath) return false + + if (result.canceled || !result.filePath) { + return false + } + await fs.promises.writeFile(result.filePath, buffer) + return true } @@ -3745,6 +4222,7 @@ async function writeComposerImage(buffer, ext = '.png') { const rawExt = String(ext || '.png') .trim() .toLowerCase() + const normalizedExt = rawExt.startsWith('.') ? rawExt : `.${rawExt}` const safeExt = /^\.[a-z0-9]{1,5}$/.test(normalizedExt) ? normalizedExt : '.png' const dir = path.join(app.getPath('userData'), 'composer-images') @@ -3753,6 +4231,7 @@ async function writeComposerImage(buffer, ext = '.png') { const random = crypto.randomBytes(3).toString('hex') const filePath = path.join(dir, `composer_${stamp}_${random}${safeExt}`) await fs.promises.writeFile(filePath, buffer) + return filePath } @@ -3777,6 +4256,7 @@ function expandUserPath(filePath) { async function previewFileTarget(rawTarget, baseDir) { const raw = String(rawTarget || '').trim() const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd() + let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), { baseDir: base, purpose: 'Preview target' @@ -3787,6 +4267,7 @@ async function previewFileTarget(rawTarget, baseDir) { } const ext = path.extname(resolved).toLowerCase() + if (!fileExists(resolved)) { return null } @@ -3858,13 +4339,21 @@ async function normalizePreviewTarget(rawTarget, baseDir) { async function filePathFromPreviewUrl(rawUrl) { const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' }) + return resolvedPath } function sendPreviewFileChanged(payload) { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:preview-file-changed', payload) } @@ -3874,6 +4363,7 @@ async function watchPreviewFile(rawUrl) { const targetName = path.basename(filePath) const id = crypto.randomBytes(12).toString('base64url') let timer = null + const watcher = fs.watch(watchDir, (_eventType, filename) => { const changedName = filename ? path.basename(String(filename)) : '' @@ -3881,17 +4371,27 @@ async function watchPreviewFile(rawUrl) { return } - if (timer) clearTimeout(timer) + if (timer) { + clearTimeout(timer) + } + timer = setTimeout(() => { timer = null - if (!fileExists(filePath)) return + + if (!fileExists(filePath)) { + return + } + sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() }) }, PREVIEW_WATCH_DEBOUNCE_MS) }) previewWatchers.set(id, { close: () => { - if (timer) clearTimeout(timer) + if (timer) { + clearTimeout(timer) + } + watcher.close() } }) @@ -3925,6 +4425,7 @@ async function waitForHermes(baseUrl, token) { while (Date.now() < deadline) { try { await fetchJson(`${baseUrl}/api/status`, token) + return } catch (error) { lastError = error @@ -3936,7 +4437,10 @@ async function waitForHermes(baseUrl, token) { } function getWindowButtonPosition() { - if (!IS_MAC) return null + if (!IS_MAC) { + return null + } + return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION } @@ -3953,16 +4457,36 @@ function getWindowState() { } function sendBackendExit(payload) { - if (!mainWindow || mainWindow.isDestroyed()) return + // Intentional soft re-home (gateway mode apply) kills the child on purpose — + // don't surface the "backend stopped" error toast / boot-failure path. + if (softRehomeInProgress) { + return + } + + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:backend-exit', payload) } function sendClosePreviewRequested() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:close-preview-requested') } @@ -3970,17 +4494,28 @@ function sendClosePreviewRequested() { // renderer's WebSocket to the local backend; the renderer reconnects on this // signal so the chat composer doesn't stay stuck on "Starting Hermes...". function sendPowerResume() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:power-resume') } let powerResumeRegistered = false function registerPowerResumeListeners() { - if (powerResumeRegistered) return + if (powerResumeRegistered) { + return + } + powerResumeRegistered = true + try { // 'resume' covers sleep/wake; 'unlock-screen' covers lock/unlock without a // full suspend. Either can drop an idle socket. @@ -3997,18 +4532,36 @@ function getAppIconPath() { } function sendOpenUpdatesRequested() { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + webContents.send('hermes:open-updates') - if (!mainWindow.isVisible()) mainWindow.show() + + if (!mainWindow.isVisible()) { + mainWindow.show() + } + mainWindow.focus() } -function sendWindowStateChanged(nextIsFullscreen) { - if (!mainWindow || mainWindow.isDestroyed()) return +function sendWindowStateChanged(nextIsFullscreen?: boolean) { + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const { webContents } = mainWindow - if (!webContents || webContents.isDestroyed()) return + + if (!webContents || webContents.isDestroyed()) { + return + } + const state = getWindowState() if (typeof nextIsFullscreen === 'boolean') { @@ -4020,10 +4573,12 @@ function sendWindowStateChanged(nextIsFullscreen) { function buildApplicationMenu() { const template = [] + const checkForUpdatesItem = { label: 'Check for Updates…', click: () => sendOpenUpdatesRequested() } + if (IS_MAC) { template.push({ label: APP_NAME, @@ -4047,14 +4602,13 @@ function buildApplicationMenu() { submenu: [ IS_MAC ? { - accelerator: 'CommandOrControl+W', - click: () => { - if (previewShortcutActive) { - sendClosePreviewRequested() - } else { - mainWindow?.close() - } - }, + // NO accelerator: on macOS a registered ⌘W is consumed by the OS + // menu before the web contents ever sees it (and registerAccelerator + // false is a no-op on mac — electron#18295). Leaving it off lets the + // `before-input-event` handler below intercept ⌘W and route it to the + // renderer's close-active-tab. Clicking the item still closes the tab + // (or window) via the same request. + click: () => sendClosePreviewRequested(), label: 'Close' } : { role: 'quit' } @@ -4130,6 +4684,7 @@ function toggleDevTools(window) { // increase versus a much better support story when WS connection or // CSP issues surface in the field. const { webContents } = window + if (webContents.isDevToolsOpened()) { webContents.closeDevTools() } else { @@ -4141,11 +4696,16 @@ function installDevToolsShortcut(window) { // F12 / Cmd+Opt+I works in both dev and packaged builds. window.webContents.on('before-input-event', (event, input) => { const key = input.key.toLowerCase() + const isInspectShortcut = input.key === 'F12' || (IS_MAC && input.meta && input.alt && key === 'i') || (!IS_MAC && input.control && input.shift && key === 'i') - if (!isInspectShortcut) return + + if (!isInspectShortcut) { + return + } + event.preventDefault() toggleDevTools(window) }) @@ -4154,9 +4714,14 @@ function installDevToolsShortcut(window) { function installPreviewShortcut(window) { window.webContents.on('before-input-event', (event, input) => { const key = String(input.key || '').toLowerCase() - const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift + const isCloseTabShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift - if (!isPreviewCloseShortcut || !previewShortcutActive) return + // Always claim ⌘W here (the File>Close item deliberately has no + // accelerator, so nothing else does). The renderer decides tab-vs-window + // — no `previewShortcutActive` gate, so it works for every closeable tab. + if (!isCloseTabShortcut) { + return + } event.preventDefault() sendClosePreviewRequested() @@ -4167,15 +4732,23 @@ function installPreviewShortcut(window) { // survives reloads/restarts) rather than a main-process JSON file. The main // process owns setZoomLevel, so we mirror each change into localStorage and // read it back on did-finish-load to re-apply after reloads or crash recovery. -const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs') +import { + applyZoomLevel, + installZoomReassertOnWindowEvents, + percentToZoomLevel, + ZOOM_STORAGE_KEY, + zoomLevelToPercent, + zoomWiringForWindowKind +} from './zoom' function setAndPersistZoomLevel(window, zoomLevel) { - if (!window || window.isDestroyed()) return - const next = clampZoomLevel(zoomLevel) - window.webContents.setZoomLevel(next) - // Keep any open settings UI in sync, including changes made via the - // keyboard shortcuts or the View menu. - window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) }) + if (!window || window.isDestroyed()) { + return + } + + // Apply + notify in one funnel so the settings UI stays in sync, including + // changes made via the keyboard shortcuts or the View menu. + const next = applyZoomLevel(window.webContents, zoomLevel) window.webContents .executeJavaScript( `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}` @@ -4184,15 +4757,22 @@ function setAndPersistZoomLevel(window, zoomLevel) { } function restorePersistedZoomLevel(window) { - if (!window || window.isDestroyed()) return + if (!window || window.isDestroyed()) { + return + } + window.webContents .executeJavaScript( `(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()` ) .then(stored => { - if (stored == null || !window || window.isDestroyed()) return - const level = clampZoomLevel(Number(stored)) - window.webContents.setZoomLevel(level) + if (stored == null || !window || window.isDestroyed()) { + return + } + + // Notify the renderer too — otherwise the Appearance UI Scale control + // can stay stuck at 100% even though the window zoom was restored. + applyZoomLevel(window.webContents, Number(stored)) }) .catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`)) } @@ -4205,9 +4785,13 @@ function installZoomShortcuts(window) { const ZOOM_STEP = 0.1 window.webContents.on('before-input-event', (event, input) => { const mod = IS_MAC ? input.meta : input.control - if (!mod || input.alt || input.shift) return + + if (!mod || input.alt || input.shift) { + return + } const key = input.key + if (key === '0') { event.preventDefault() setAndPersistZoomLevel(window, 0) @@ -4260,7 +4844,10 @@ function installContextMenu(window) { } if (hasLink) { - if (template.length) template.push({ type: 'separator' }) + if (template.length) { + template.push({ type: 'separator' }) + } + template.push( { label: 'Open Link', @@ -4279,7 +4866,9 @@ function installContextMenu(window) { const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : [] if (isEditable && params.misspelledWord && suggestions.length > 0) { - if (template.length) template.push({ type: 'separator' }) + if (template.length) { + template.push({ type: 'separator' }) + } for (const suggestion of suggestions.slice(0, 5)) { template.push({ @@ -4296,7 +4885,10 @@ function installContextMenu(window) { } if (hasSelection || isEditable) { - if (template.length) template.push({ type: 'separator' }) + if (template.length) { + template.push({ type: 'separator' }) + } + if (isEditable) { template.push( { role: 'cut', enabled: params.editFlags.canCut }, @@ -4332,15 +4924,19 @@ function isAudioCapturePermission(permission, details) { if (permission === 'audioCapture') { return true } + if (permission !== 'media') { return false } + const mediaTypes = details?.mediaTypes + if (!Array.isArray(mediaTypes) || mediaTypes.length === 0) { // Windows: mediaTypes is often empty for a mic request. Don't deny on // missing metadata. (A video request would carry mediaTypes:['video'].) return true } + return mediaTypes.includes('audio') && !mediaTypes.includes('video') } @@ -4355,9 +4951,10 @@ function installMediaPermissions() { // the check defaults to false and the mic is denied before the request // handler ever runs. session.defaultSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => { - if (permission === 'media' || permission === 'audioCapture') { + if (permission === 'media' || permission === ('audioCapture' as any) /* todo: is this needed? */) { // details.mediaType is a single string here (not the mediaTypes array). const mediaType = details?.mediaType + if (mediaType === 'video') { return false } @@ -4401,27 +4998,38 @@ function installMediaPermissions() { const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth' function getOauthSession() { - if (oauthSession || !app.isReady()) return oauthSession + if (oauthSession || !app.isReady()) { + return oauthSession + } + oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION) + return oauthSession } // Bare + prefixed variants of the session cookies live in -// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See +// connection-config.ts (cookiesHaveSession / cookiesHaveLiveSession). See // that module for details. async function hasOauthSessionCookie(baseUrl) { const sess = getOauthSession() - if (!sess) return false + + if (!sess) { + return false + } + const parsed = new URL(baseUrl) + try { // Query by URL so the cookie jar applies Domain/Path/Secure scoping for us. const cookies = await sess.cookies.get({ url: baseUrl }) + return cookiesHaveSession(cookies) } catch { // Fall back to a host match if the URL query path errors. try { const cookies = await sess.cookies.get({ domain: parsed.hostname }) + return cookiesHaveSession(cookies) } catch { return false @@ -4438,14 +5046,21 @@ async function hasOauthSessionCookie(baseUrl) { // cheap early-out before attempting a network round-trip in resolveRemoteBackend. async function hasLiveOauthSession(baseUrl) { const sess = getOauthSession() - if (!sess) return false + + if (!sess) { + return false + } + const parsed = new URL(baseUrl) + try { const cookies = await sess.cookies.get({ url: baseUrl }) + return cookiesHaveLiveSession(cookies) } catch { try { const cookies = await sess.cookies.get({ domain: parsed.hostname }) + return cookiesHaveLiveSession(cookies) } catch { return false @@ -4455,13 +5070,18 @@ async function hasLiveOauthSession(baseUrl) { async function clearOauthSession(baseUrl) { const sess = getOauthSession() - if (!sess) return + + if (!sess) { + return + } + try { const cookies = await sess.cookies.get(baseUrl ? { url: baseUrl } : {}) await Promise.all( cookies.map(c => { const scheme = c.secure ? 'https' : 'http' const cookieUrl = `${scheme}://${c.domain.replace(/^\./, '')}${c.path || '/'}` + return sess.cookies.remove(cookieUrl, c.name).catch(() => undefined) }) ) @@ -4470,51 +5090,97 @@ async function clearOauthSession(baseUrl) { } } -// Open the gateway's /login page in a visible window using the OAuth session -// partition, and resolve once the access-token cookie appears (login done) or -// reject if the user closes the window first. The window navigates through the -// IDP and back to /auth/callback, which sets the session cookies on the -// partition; we poll the cookie jar rather than try to read the HttpOnly value. -function openOauthLoginWindow(baseUrl) { +// Open a gateway login window in the OAuth session partition, resolving once +// the access-token cookie appears (login done) or rejecting if the user closes +// the window first. The window navigates through the IDP and back to +// /auth/callback, which sets the session cookies on the partition; we poll the +// cookie jar rather than try to read the HttpOnly value. +// +// `silent` selects the URL the window loads, which decides interactive-vs-silent: +// - silent=false (default): load ``/login`` — the public interstitial that +// renders the "Log in with X" provider chooser. This is the interactive +// remote-gateway login the settings UI drives. +// - silent=true: load the PROTECTED root ``/`` instead. ``/login`` is a public +// route, so loading it NEVER triggers the gate's auto-SSO and always shows +// the chooser. Loading a protected page with no session cookie makes the +// gate run ``_auto_sso_response``: single registered provider + a live +// portal session in this partition → a silent 302 through +// ``/auth/login`` → portal ``/oauth/authorize`` (auto-approves org members) +// → ``/auth/callback``, which sets the gateway cookie with NO interactive +// prompt. This is the per-agent cloud cascade (decisions.md Q5). +function openOauthLoginWindow(baseUrl, { silent = false } = {}) { return new Promise((resolve, reject) => { if (!app.isReady()) { reject(new Error('Desktop is not ready to start an OAuth login.')) + return } + const sess = getOauthSession() + if (!sess) { reject(new Error('OAuth session partition is unavailable.')) + return } let settled = false let win = null let pollTimer = null + let revealTimer = null const finish = err => { - if (settled) return + if (settled) { + return + } + settled = true - if (pollTimer) clearInterval(pollTimer) + + if (pollTimer) { + clearInterval(pollTimer) + } + + if (revealTimer) { + clearTimeout(revealTimer) + } + try { - if (win && !win.isDestroyed()) win.destroy() + if (win && !win.isDestroyed()) { + win.destroy() + } } catch { // window already torn down } - if (err) reject(err) - else resolve({ baseUrl, ok: true }) + + if (err) { + reject(err) + } else { + resolve({ baseUrl, ok: true }) + } } const checkCookie = async () => { - if (settled) return - if (await hasOauthSessionCookie(baseUrl)) finish(null) + if (settled) { + return + } + + if (await hasOauthSessionCookie(baseUrl)) { + finish(null) + } } try { win = new BrowserWindow({ width: 520, height: 720, - title: 'Sign in to Hermes gateway', + title: silent ? 'Connecting to Hermes Cloud agent…' : 'Sign in to Hermes gateway', autoHideMenuBar: true, + // Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in + // well under a second, so the window normally never needs to show. We + // only reveal it as a fallback if the cascade DOESN'T complete quickly + // (e.g. the portal session lapsed and the gate fell through to the + // interactive chooser) — see the reveal timer below. + show: !silent, webPreferences: { contextIsolation: true, nodeIntegration: false, @@ -4525,6 +5191,7 @@ function openOauthLoginWindow(baseUrl) { }) } catch (error) { finish(error instanceof Error ? error : new Error(String(error))) + return } @@ -4536,14 +5203,37 @@ function openOauthLoginWindow(baseUrl) { win.webContents.on('did-frame-navigate', () => void checkCookie()) pollTimer = setInterval(() => void checkCookie(), 750) + // Silent-mode reveal fallback: if the cascade hasn't settled shortly, the + // auto-SSO didn't go through silently (no portal session, multi-provider, + // loop-guard tripped, etc.) and the window is now showing an interactive + // page. Reveal it so the user can complete sign-in manually rather than + // staring at nothing. Cleared on finish(). + if (silent && win) { + revealTimer = setTimeout(() => { + try { + if (!settled && win && !win.isDestroyed() && !win.isVisible()) { + win.show() + } + } catch { + // window torn down + } + }, 2500) + } + win.on('closed', () => { - if (!settled) finish(new Error('Login window closed before authentication completed.')) + if (!settled) { + finish(new Error('Login window closed before authentication completed.')) + } }) // ``next`` is intentionally omitted: the gateway lands on ``/`` after // login, which is a valid authenticated page that sets the cookies. We // only care that the cookie jar is populated. - const loginUrl = `${normalizeRemoteBaseUrl(baseUrl)}/login` + // + // silent=true loads the protected root so the gate auto-SSOs (no chooser); + // silent=false loads the public ``/login`` chooser for interactive sign-in. + const normalizedBase = normalizeRemoteBaseUrl(baseUrl) + const loginUrl = silent ? `${normalizedBase}/` : `${normalizedBase}/login` win.loadURL(loginUrl).catch(error => { finish(error instanceof Error ? error : new Error(String(error))) }) @@ -4553,24 +5243,32 @@ function openOauthLoginWindow(baseUrl) { // JSON request routed through the OAuth session partition so the HttpOnly // session cookie is attached automatically by Electron's net stack. Used for // authed REST against a gated gateway, including minting WS tickets. -function fetchJsonViaOauthSession(url, options = {}) { +function fetchJsonViaOauthSession(url, options: any = {}) { return new Promise((resolve, reject) => { const sess = getOauthSession() + if (!sess) { reject(new Error('OAuth session partition is unavailable.')) + return } + let parsed + try { parsed = new URL(url) } catch (error) { reject(new Error(`Invalid URL: ${error.message}`)) + return } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + return } + const body = serializeJsonBody(options.body) const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) @@ -4580,17 +5278,21 @@ function fetchJsonViaOauthSession(url, options = {}) { session: sess, useSessionCookies: true, redirect: 'follow' - }) + } as any) + setJsonRequestHeaders(request) let timedOut = false + const timer = setTimeout(() => { timedOut = true + try { request.abort() } catch { // already finished } + reject(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }, timeoutMs) @@ -4598,26 +5300,37 @@ function fetchJsonViaOauthSession(url, options = {}) { const chunks = [] res.on('data', chunk => chunks.push(Buffer.from(chunk))) res.on('end', () => { - if (timedOut) return + if (timedOut) { + return + } + clearTimeout(timer) const text = Buffer.concat(chunks).toString('utf8') const statusCode = res.statusCode || 500 + if (statusCode >= 400) { - const err = new Error(`${statusCode}: ${text || ''}`) + const err = new Error(`${statusCode}: ${text || ''}`) as any err.statusCode = statusCode reject(err) + return } + if (!text) { resolve(null) + return } + const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || res.headers['Content-Type'] || '') + if (looksHtml || contentType.includes('text/html')) { reject(new Error(`Expected JSON from ${url} but got HTML (status ${statusCode}).`)) + return } + try { resolve(JSON.parse(text)) } catch { @@ -4626,11 +5339,18 @@ function fetchJsonViaOauthSession(url, options = {}) { }) }) request.on('error', error => { - if (timedOut) return + if (timedOut) { + return + } + clearTimeout(timer) reject(error) }) - if (body) request.write(body) + + if (body) { + request.write(body) + } + request.end() }) } @@ -4639,14 +5359,17 @@ function fetchJsonViaOauthSession(url, options = {}) { // Throws (with statusCode 401) if the session cookie is missing/expired — // callers treat that as "needs re-login". async function mintGatewayWsTicket(baseUrl) { - const body = await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, { + const body = (await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, { method: 'POST', timeoutMs: 8_000 - }) + })) as any + const ticket = body?.ticket + if (!ticket || typeof ticket !== 'string') { throw new Error('Gateway did not return a WS ticket.') } + return ticket } @@ -4664,14 +5387,321 @@ async function freshGatewayWsUrl(profile) { // the wrong profile's DB. A null/empty profile resolves to the primary, so // legacy callers and single-profile users are unchanged. const connection = await ensureBackend(profile) + if (connection.authMode === 'oauth') { const ticket = await mintGatewayWsTicket(connection.baseUrl) + return buildGatewayWsUrlWithTicket(connection.baseUrl, ticket) } + // Local/token: the cached wsUrl already carries the (long-lived) token. return connection.wsUrl } +// --- Hermes Cloud discovery + silent per-agent sign-in (cloud-auto-discovery +// Phase 3) --------------------------------------------------------------- +// +// The "cloud" connection mode lets a user sign in to the Nous portal ONCE in +// the OAuth session partition, then (a) discover their hosted agents and (b) +// connect to any of them with no second interactive sign-in. Both ride the one +// portal session cookie living in `persist:hermes-remote-oauth`: +// - discovery → GET {portal}/api/agents over the partition-bound net; the +// portal session cookie authenticates it (NAS Phase 2.5 accepts the cookie). +// - cascade → opening an agent's own /login in the same partition hits the +// portal's silent auto-approve (org member, existing session) and 302s back +// with that agent's session cookie — no prompt. Each agent still completes +// its own PKCE exchange; SSO removes the human click, not a security check. + +// Canonical Nous portal base URL, overridable for staging/dev. Mirrors the CLI +// convention (hermes_cli/auth.py DEFAULT_NOUS_PORTAL_URL + the same env names) +// so a single override flips every Hermes surface to the same portal. +const DEFAULT_NOUS_PORTAL_URL = 'https://portal.nousresearch.com' + +function resolvePortalBaseUrl() { + const raw = process.env.HERMES_PORTAL_BASE_URL || process.env.NOUS_PORTAL_BASE_URL || DEFAULT_NOUS_PORTAL_URL + + return String(raw).trim().replace(/\/+$/, '') +} + +// Whether the OAuth partition currently holds a live Nous portal session — the +// credential that powers both discovery and the silent cascade. The portal +// authenticates via PRIVY, not the Hermes gateway session cookies, so this +// checks for the `privy-token` cookie on the portal host (NOT +// hasLiveOauthSession, which looks for hermes_session_at/rt that the portal +// never sets). See connection-config.ts cookiesHavePrivySession. +async function hasLivePortalSession() { + const sess = getOauthSession() + + if (!sess) { + return false + } + + const portalBaseUrl = resolvePortalBaseUrl() + const parsed = new URL(portalBaseUrl) + + try { + const cookies = await sess.cookies.get({ url: portalBaseUrl }) + + return cookiesHavePrivySession(cookies) + } catch { + try { + const cookies = await sess.cookies.get({ domain: parsed.hostname }) + + return cookiesHavePrivySession(cookies) + } catch { + return false + } + } +} + +// Drive a one-time interactive portal sign-in in the OAuth partition. Unlike +// openOauthLoginWindow (which targets a gateway's /login), this lands on the +// portal itself so the resulting session cookie is portal-scoped — the cookie +// that authenticates discovery AND is reused for every silent per-agent +// cascade. Resolves once the portal session cookie appears. +function openPortalLoginWindow() { + const portalBaseUrl = resolvePortalBaseUrl() + + return new Promise((resolve, reject) => { + if (!app.isReady()) { + reject(new Error('Desktop is not ready to start a Hermes Cloud sign-in.')) + + return + } + + const sess = getOauthSession() + + if (!sess) { + reject(new Error('OAuth session partition is unavailable.')) + + return + } + + let settled = false + let win = null + let pollTimer = null + + const finish = err => { + if (settled) { + return + } + settled = true + + if (pollTimer) { + clearInterval(pollTimer) + } + + try { + if (win && !win.isDestroyed()) { + win.destroy() + } + } catch { + // window already torn down + } + + if (err) { + reject(err) + } else { + resolve({ portalBaseUrl, ok: true }) + } + } + + const checkCookie = async () => { + if (settled) { + return + } + + // A live portal (Privy) session cookie means sign-in completed. + if (await hasLivePortalSession()) { + finish(null) + } + } + + try { + win = new BrowserWindow({ + width: 520, + height: 720, + title: 'Sign in to Hermes Cloud', + autoHideMenuBar: true, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + session: sess, + webSecurity: true + } + }) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + + return + } + + win.webContents.on('did-navigate', () => void checkCookie()) + win.webContents.on('did-redirect-navigation', () => void checkCookie()) + win.webContents.on('did-frame-navigate', () => void checkCookie()) + pollTimer = setInterval(() => void checkCookie(), 750) + + win.on('closed', () => { + if (!settled) { + finish(new Error('Sign-in window closed before authentication completed.')) + } + }) + + // Land on the portal root; any authenticated portal page sets the session + // cookie. We only care that the partition cookie jar is populated. + win.loadURL(portalBaseUrl).catch(error => { + finish(error instanceof Error ? error : new Error(String(error))) + }) + }) +} + +// Discover the hosted (Hermes Cloud) agents the signed-in user can see. Calls +// the NAS trimmed-summary endpoint over the partition-bound net, so the portal +// session cookie is attached automatically (no bearer needed — NAS accepts the +// cookie). Returns { agents } on success, or { needsOrgSelection: true, orgs } +// when the user belongs to multiple orgs and hasn't picked one yet (NAS 409 +// org_selection_required). Pass `org` (a slug/id from a prior org list) to +// scope discovery to that org. Throws a needsCloudLogin-tagged error when no +// portal session is present. +async function discoverCloudAgents(org?: string) { + const portalBaseUrl = resolvePortalBaseUrl() + + if (!(await hasLivePortalSession())) { + const err = new Error( + 'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.' + ) as any + err.needsCloudLogin = true + throw err + } + + const orgQuery = org ? `?org=${encodeURIComponent(org)}` : '' + let body + + try { + body = (await fetchJsonViaOauthSession(`${portalBaseUrl}/api/agents${orgQuery}`, { + method: 'GET', + timeoutMs: 15_000 + })) as any + } catch (error) { + // A 401 means the portal session lapsed between the liveness check and the + // call — surface it as a re-login, not a generic failure. + if (error && error.statusCode === 401) { + const err = new Error('Your Hermes Cloud session has expired. Open Settings → Gateway and sign in again.') as any + err.needsCloudLogin = true + err.cause = error + throw err + } + + // A 409 means we're a multi-org user who hasn't picked an org. The body + // carries the user's org list; surface it so the renderer shows a picker + // and re-calls discovery with the chosen org. (fetchJsonViaOauthSession + // throws on >=400 with err.statusCode + err.message "409: <json body>".) + if (error && error.statusCode === 409) { + const orgs = parseOrgSelectionError(error) + + if (orgs) { + return { needsOrgSelection: true, orgs } + } + } + + throw error + } + + return { agents: trimCloudAgents(body), org: trimCloudOrg(body?.org) } +} + +// Project a NAS response org ({ id, slug, name, isPersonal }) to the trimmed +// shape the renderer persists, or null when absent/malformed. +function trimCloudOrg(org) { + if (!org || typeof org !== 'object' || typeof org.id !== 'string') { + return null + } + + return { + id: org.id, + slug: typeof org.slug === 'string' ? org.slug : null, + name: typeof org.name === 'string' ? org.name : org.id, + isPersonal: Boolean(org.isPersonal), + role: typeof org.role === 'string' ? org.role : 'MEMBER' + } +} + +// Extract the org list from a 409 org_selection_required error body. The error +// message is "409: <raw json>" (see fetchJsonViaOauthSession); parse defensively +// and return null if it isn't the shape we expect (caller then rethrows). +function parseOrgSelectionError(error) { + const msg = String(error?.message || '') + const jsonStart = msg.indexOf('{') + + if (jsonStart < 0) { + return null + } + + let parsed + + try { + parsed = JSON.parse(msg.slice(jsonStart)) + } catch { + return null + } + + if (parsed?.error !== 'org_selection_required' || !Array.isArray(parsed.orgs)) { + return null + } + + return parsed.orgs + .filter(o => o && typeof o === 'object' && typeof o.id === 'string') + .map(o => ({ + id: o.id, + slug: typeof o.slug === 'string' ? o.slug : null, + name: typeof o.name === 'string' ? o.name : o.id, + isPersonal: Boolean(o.isPersonal), + role: typeof o.role === 'string' ? o.role : 'MEMBER' + })) +} + +// Project NAS's agent rows to the trimmed DTO the renderer consumes. +function trimCloudAgents(body) { + const agents = Array.isArray(body?.agents) ? body.agents : [] + + return agents + .filter(a => a && typeof a === 'object' && typeof a.id === 'string') + .map(a => ({ + id: a.id, + name: typeof a.name === 'string' ? a.name : a.id, + status: typeof a.status === 'string' ? a.status : 'unknown', + dashboardUrl: typeof a.dashboardUrl === 'string' ? a.dashboardUrl : null, + dashboardGatewayState: typeof a.dashboardGatewayState === 'string' ? a.dashboardGatewayState : 'unknown' + })) +} + +// Silent per-agent sign-in: open the selected agent dashboard's /login in the +// SAME OAuth partition. Because the user already holds a live portal session +// there, the agent's /oauth/authorize auto-approves (org member) and 302s back, +// setting that agent's gateway session cookie WITHOUT a second interactive +// prompt. Reuses openOauthLoginWindow — the window self-closes the instant the +// agent's session cookie lands (a silent flow finishes in well under a second; +// if the portal session were absent it would fall through to an interactive +// login, which the discovery gate already prevents). Returns once the agent's +// gateway session cookie is present. +async function cloudAgentSilentSignIn(dashboardUrl) { + const baseUrl = normalizeRemoteBaseUrl(dashboardUrl) + + // Pre-req: a live portal session must exist, or this would surface an + // interactive prompt rather than a silent cascade. Discovery already gates on + // this, but a selection can arrive after the session lapsed. + if (!(await hasLivePortalSession())) { + const err = new Error('Your Hermes Cloud session has expired. Sign in to Hermes Cloud again.') as any + err.needsCloudLogin = true + throw err + } + + await openOauthLoginWindow(baseUrl, { silent: true }) + + return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) } +} + function encryptDesktopSecret(value) { return encryptDesktopSecretStrict(value, safeStorage) } @@ -4701,29 +5731,54 @@ function decryptDesktopSecret(secret) { // Validate + normalize the per-profile remote overrides map read from disk. // Drops malformed names/entries and keeps only the recognized fields so a // hand-edited or stale connection.json can't inject junk into resolution. -function sanitizeConnectionProfiles(raw) { +function sanitizeConnectionProfiles(raw: Record<string, any>) { if (!raw || typeof raw !== 'object') { return {} } const out = {} + for (const [name, entry] of Object.entries(raw)) { if (!entry || typeof entry !== 'object') { continue } + if (name !== 'default' && !PROFILE_NAME_RE.test(name)) { continue } - const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' } + const cleaned: { + mode: 'remote' | 'local' | 'cloud' + url?: string + authMode?: string + token?: object + org?: string + } = { + mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local' + } + const url = String(entry.url || '').trim() + if (url) { cleaned.url = url } + cleaned.authMode = normAuthMode(entry.authMode) - if (entry.token && typeof entry.token === 'object') { + + if ((entry as any).token && typeof entry.token === 'object') { cleaned.token = entry.token } + + // Preserve the Hermes Cloud org tag on cloud-mode entries so Settings can + // reopen into the same org for a per-profile cloud connection. + if (cleaned.mode === 'cloud') { + const org = String(entry.org || '').trim() + + if (org) { + cleaned.org = org + } + } + out[name] = cleaned } @@ -4735,6 +5790,7 @@ function readDesktopConnectionConfig() { // process or an external tool). Our own writes update the cache inline // via writeDesktopConnectionConfig, but external changes would be missed. let mtime = null + try { mtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs } catch { @@ -4758,7 +5814,7 @@ function readDesktopConnectionConfig() { // backward compatibility with configs written before OAuth support. remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token' config = { - mode: parsed.mode === 'remote' ? 'remote' : 'local', + mode: modeIsRemoteLike(parsed.mode) ? parsed.mode : 'local', remote, // Per-profile remote overrides: each profile may point at its own // backend (local spawn or its own remote URL). Preserved verbatim so @@ -4829,9 +5885,14 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const remoteToken = decryptDesktopSecret(block.token) const authMode = normAuthMode(block.authMode) const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '') - const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local' + // The env override forces a plain remote connection. Otherwise reflect the + // saved mode, preserving 'cloud' (a Hermes Cloud connection — Q6) so the UI + // reopens into the cloud picker; any non-remote-like value collapses to local. + const savedMode = key ? scoped?.mode : config.mode + const mode = envOverride ? 'remote' : modeIsRemoteLike(savedMode) ? savedMode : 'local' let remoteOauthConnected = false + if (authMode === 'oauth' && remoteUrl) { try { // Display signal: treat a live RT cookie as "connected" even if the AT @@ -4851,6 +5912,9 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon remoteAuthMode: authMode, remoteOauthConnected, remoteUrl, + // The persisted Hermes Cloud org (slug/id) for a cloud connection, or '' for + // remote/local. Lets Settings → Gateway reopen into the same org. + cloudOrg: mode === 'cloud' ? String(block.org || '') : '', remoteTokenPreview: tokenPreview(remoteToken), remoteTokenSet: Boolean(remoteToken), // The env override only forces the global/primary connection; a per-profile @@ -4862,24 +5926,57 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon // Build + validate a `{ url, authMode, token }` remote block. OAuth gateways // authenticate via the login-window session cookie (verified at connect time in // resolveRemoteBackend), so only token-auth remotes require a saved token. -function buildRemoteBlock(remoteUrl, authMode, token) { +// `org` (optional) is the Hermes Cloud org slug/id the instance was discovered +// under — persisted so Settings can reopen into the same org; omitted from the +// block when empty so plain remote connections stay unchanged. +function buildRemoteBlock(remoteUrl, authMode, token, org?: string) { if (authMode !== 'oauth' && !decryptDesktopSecret(token)) { throw new Error('Remote gateway session token is required.') } - return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token } + + const block: { url: string; authMode: string; token: object; org?: string } = { + url: normalizeRemoteBaseUrl(remoteUrl), + authMode, + token + } + const orgValue = typeof org === 'string' ? org.trim() : '' + + if (orgValue) { + block.org = orgValue + } + + return block } -function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) { +function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopConnectionConfig(), options: any = {}) { const persistToken = options.persistToken !== false const key = connectionScopeKey(input.profile) - const mode = input.mode === 'remote' ? 'remote' : 'local' + // 'cloud' and 'remote' both persist a remote-shaped block; 'cloud' is + // remembered as its own provenance (Q6) and resolves to remote downstream. + // Anything else collapses to local. + const mode = modeIsRemoteLike(input.mode) ? input.mode : 'local' + const remoteLike = modeIsRemoteLike(mode) // The block being edited: a per-profile entry or the global remote block. - const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {} + const rawExistingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {} + // Leaving a CLOUD connection unselects it: a cloud block's url/org/token + // describe a discovered Hermes Cloud instance, NOT a user-owned remote gateway, + // so switching to local or remote must NOT inherit them (otherwise the stale + // cloud URL lingers and re-selecting Cloud looks "already connected"). When the + // saved block was cloud and the new mode is not cloud, start from an empty + // block. (remote↔local toggles still preserve a real remote URL as before.) + const existingMode = key ? existing.profiles?.[key]?.mode : existing.mode + const leavingCloud = existingMode === 'cloud' && mode !== 'cloud' + const existingBlock = leavingCloud ? {} : rawExistingBlock const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim() // authMode: explicit input wins; otherwise inherit the saved value, default 'token'. const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode) + // Cloud org: only meaningful for 'cloud' mode. Explicit input wins; otherwise + // inherit the saved org. A plain 'remote' connection never carries an org + // (switching cloud→remote drops it), so it stays unset unless mode is cloud. + const cloudOrg = mode === 'cloud' ? String(input.cloudOrg ?? existingBlock.org ?? '').trim() : '' const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : '' + const nextToken = incomingToken ? persistToken ? encryptDesktopSecret(incomingToken) @@ -4887,21 +5984,27 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect : existingBlock.token if (key) { - // Per-profile scope: a remote entry pins this profile to its own backend; a - // local entry clears the override so the profile inherits the default. + // Per-profile scope: a remote/cloud entry pins this profile to its own + // backend; a local entry clears the override so the profile inherits the + // default. The mode tag (remote vs cloud) is preserved on the entry. const profiles = { ...(existing.profiles || {}) } - if (mode === 'remote') { - profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) } + + if (remoteLike) { + profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) } } else { delete profiles[key] } - return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles } + + return { + mode: modeIsRemoteLike(existing.mode) ? existing.mode : 'local', + remote: existing.remote || {}, + profiles + } } - const nextRemote = - mode === 'remote' - ? buildRemoteBlock(remoteUrl, authMode, nextToken) - : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } + const nextRemote = remoteLike + ? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) + : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } // Preserve per-profile overrides when saving the global connection. return { mode, remote: nextRemote, profiles: existing.profiles || {} } @@ -4928,18 +6031,21 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) { const err = new Error( 'Remote Hermes gateway uses OAuth, but you are not signed in. ' + 'Open Settings → Gateway and click "Sign in", or switch back to Local.' - ) + ) as any + err.needsOauthLogin = true throw err } let ticket + try { ticket = await mintGatewayWsTicket(baseUrl) } catch (error) { const err = new Error( 'Your remote gateway session has expired. ' + 'Open Settings → Gateway and click "Sign in" again.' - ) + ) as any + err.needsOauthLogin = true err.cause = error throw err @@ -4987,14 +6093,17 @@ async function resolveRemoteBackend(profile) { // over the env override so an explicitly-configured profile always // reaches its intended backend. const override = profileRemoteOverride(config, profile) + if (override) { const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token) + return buildRemoteConnection(override.url, override.authMode, token, 'profile') } // 2. Env override (global, token-auth only). const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN + if (rawEnvUrl) { if (!rawEnvToken) { throw new Error( @@ -5002,15 +6111,18 @@ async function resolveRemoteBackend(profile) { 'Both must be provided to connect to a remote Hermes backend.' ) } + return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env') } - // 3. Global remote. - if (config.mode !== 'remote') { + // 3. Global remote (or cloud — cloud resolves to a remote backend, Q6). + if (!modeIsRemoteLike(config.mode)) { return null } + const authMode = normAuthMode(config.remote?.authMode) const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token) + return buildRemoteConnection(config.remote?.url, authMode, token, 'settings') } @@ -5024,17 +6136,20 @@ function profileHasRemoteOverride(profile) { function configuredRemoteProfileNames() { const config = readDesktopConnectionConfig() + return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name)) } // True when the app is in app-global remote mode (Settings → "All profiles" → -// Remote, or the env override): a SINGLE remote backend serves every profile via -// ?profile=. Distinct from per-profile overrides — here there's one host for all. +// Remote/Cloud, or the env override): a SINGLE remote backend serves every +// profile via ?profile=. Cloud counts — it resolves to a remote backend (Q6). +// Distinct from per-profile overrides — here there's one host for all. function globalRemoteActive() { if (process.env.HERMES_DESKTOP_REMOTE_URL) { return true } - return readDesktopConnectionConfig().mode === 'remote' + + return modeIsRemoteLike(readDesktopConnectionConfig().mode) } // GET a profile's resolved backend (remote pool or local primary), parsed JSON. @@ -5043,10 +6158,11 @@ async function fetchJsonForProfile(profile, path) { } // Issue an arbitrary method against a profile's resolved backend, parsed JSON. -async function requestJsonForProfile(profile, path, method, body) { +async function requestJsonForProfile(profile: string, path: string, method: string, body?: string) { const conn = await ensureBackend(profile) const url = `${conn.baseUrl}${path}` const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS } + return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts) } @@ -5066,9 +6182,10 @@ async function probeRemoteAuthMode(rawUrl) { const baseUrl = normalizeRemoteBaseUrl(rawUrl) let status + try { status = await fetchPublicJson(`${baseUrl}/api/status`, { timeoutMs: 8_000 }) - } catch (error) { + } catch (error: any) { return { baseUrl, reachable: false, @@ -5089,7 +6206,8 @@ async function probeRemoteAuthMode(rawUrl) { // an OAuth-redirect one (``supports_password``). A failure here doesn't // change the auth mode, so swallow it. try { - const body = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 }) + const body = (await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 })) as any + if (Array.isArray(body?.providers)) { providers = body.providers .filter(p => p && typeof p === 'object') @@ -5115,14 +6233,16 @@ async function probeRemoteAuthMode(rawUrl) { } } -async function testDesktopConnectionConfig(input = {}) { +async function testDesktopConnectionConfig(input: any = {}) { const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false }) const key = connectionScopeKey(input.profile) // The block under test: a per-profile entry or the global remote. Coerce has // already normalized the URL and resolved token inheritance for the scope. const block = key ? config.profiles?.[key] || null : config.remote + const wantRemote = - block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block) + modeIsRemoteLike(block?.mode) || (!key && modeIsRemoteLike(config.mode)) || (modeIsRemoteLike(input.mode) && block) + // ``/api/status`` is public on every gateway (no creds needed), so a // reachability test works for local, token, and oauth modes alike — we only // need a base URL. For a remote config we normalize the URL from the input; @@ -5130,9 +6250,11 @@ async function testDesktopConnectionConfig(input = {}) { let baseUrl let token = null let authMode = 'token' + if (wantRemote && block?.url) { baseUrl = normalizeRemoteBaseUrl(block.url) authMode = normAuthMode(block.authMode) + if (authMode !== 'oauth') { token = decryptDesktopSecret(block.token) } @@ -5142,7 +6264,8 @@ async function testDesktopConnectionConfig(input = {}) { token = remote.token authMode = normAuthMode(remote.authMode) } - const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 }) + + const status = (await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })) as any // The HTTP status check above proves the backend is reachable, but the chat // surface only works once the renderer's live WebSocket to ``/api/ws`` @@ -5152,11 +6275,13 @@ async function testDesktopConnectionConfig(input = {}) { // connect to Hermes gateway". Mirror the renderer's connect here so the test // reflects the full path the app actually uses. const wsUrl = await resolveTestWsUrl(baseUrl, authMode, token, { mintTicket: mintGatewayWsTicket }) + // Skip the WS leg only when the runtime genuinely lacks a WebSocket (so an // older Electron/Node never fails the test spuriously); Electron's main // process ships a global WebSocket on every supported version. if (wsUrl && typeof globalThis.WebSocket === 'function') { const probe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket }) + if (!probe.ok) { throw new Error( `Reached the gateway over HTTP, but the live WebSocket (/api/ws) connection failed: ${probe.reason} ` + @@ -5186,49 +6311,72 @@ function resetBootProgressForReconnect() { } function stopBackendChild(child) { - if (!child || child.killed) return - try { - if (IS_WINDOWS && Number.isInteger(child.pid)) { - forceKillProcessTree(child.pid) - } else { - child.kill('SIGTERM') - } - } catch { - // Already gone. - } + stopBackendChildImpl(child, { forceKillProcessTree, isWindows: IS_WINDOWS }) } -function resetHermesConnection() { +// Soft gateway-mode apply: tear down the primary without resetting boot UI or +// reloading the renderer. The shell stays up; the renderer wipes session lists +// (so skeletons retrigger) and re-dials. Distinct from hard re-home (profile +// switch / crash recovery), which still resets boot progress + reloads. +function resetHermesConnection({ soft = false } = {}) { connectionPromise = null backendStartFailure = null stopBackendChild(hermesProcess) hermesProcess = null - resetBootProgressForReconnect() + + if (!soft) { + resetBootProgressForReconnect() + } } // Re-home the primary backend: reset connection state, then wait for the live // dashboard process to actually exit (SIGKILL after 5s) so the next // startHermes() spawns fresh instead of racing the dying one. Shared by the // connection-config and profile switch flows. -async function teardownPrimaryBackendAndWait() { +async function teardownPrimaryBackendAndWait({ soft = false } = {}) { // Capture the reference before resetHermesConnection() nulls hermesProcess. const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null - resetHermesConnection() - await waitForBackendExit(dying) + if (soft) { + softRehomeInProgress = true + } + + try { + resetHermesConnection({ soft }) + await waitForBackendExit(dying) + } finally { + if (soft) { + softRehomeInProgress = false + } + } +} + +function sendConnectionApplied() { + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + + const { webContents } = mainWindow + + if (!webContents || webContents.isDestroyed()) { + return + } + + webContents.send('hermes:connection:applied') } async function waitForBackendExit(child, timeoutMs = 5000) { if (!child) { return } + if (child.exitCode !== null || child.signalCode !== null) { return } - await new Promise(resolve => { + await new Promise<void>(resolve => { const timer = setTimeout(() => { try { if (IS_WINDOWS && Number.isInteger(child.pid)) { @@ -5239,8 +6387,10 @@ async function waitForBackendExit(child, timeoutMs = 5000) { } catch { // Already gone. } + resolve() }, timeoutMs) + child.once('exit', () => { clearTimeout(timer) resolve() @@ -5267,8 +6417,10 @@ async function ensureBackend(profile) { } const existing = backendPool.get(key) + if (existing) { existing.lastActiveAt = Date.now() + return existing.connectionPromise } @@ -5281,6 +6433,7 @@ async function ensureBackend(profile) { }) backendPool.set(key, entry) startPoolIdleReaper() + return entry.connectionPromise } @@ -5289,9 +6442,16 @@ async function ensureBackend(profile) { // streaming, since the main process can't see the direct renderer↔backend WS. function touchPoolBackend(profile) { const key = profile && String(profile).trim() ? String(profile).trim() : null - if (!key) return + + if (!key) { + return + } + const entry = backendPool.get(key) - if (entry) entry.lastActiveAt = Date.now() + + if (entry) { + entry.lastActiveAt = Date.now() + } } // Evict least-recently-used pool backends until at most `keep` remain — but only @@ -5299,14 +6459,23 @@ function touchPoolBackend(profile) { // window). When every backend is actively kept alive we let the pool exceed the // soft cap rather than kill a running session. function evictLruPoolBackends(keep) { - if (backendPool.size <= keep) return + if (backendPool.size <= keep) { + return + } + const now = Date.now() + const evictable = [...backendPool.entries()] .filter(([, entry]) => now - (entry.lastActiveAt || 0) > POOL_KEEPALIVE_FRESH_MS) .sort((a, b) => (a[1].lastActiveAt || 0) - (b[1].lastActiveAt || 0)) + let removable = backendPool.size - Math.max(0, keep) + for (const [profile] of evictable) { - if (removable <= 0) break + if (removable <= 0) { + break + } + rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`) stopPoolBackend(profile) removable -= 1 @@ -5314,21 +6483,29 @@ function evictLruPoolBackends(keep) { } function startPoolIdleReaper() { - if (poolIdleReaper) return + if (poolIdleReaper) { + return + } + poolIdleReaper = setInterval(() => { const now = Date.now() + for (const [profile, entry] of [...backendPool.entries()]) { if (now - (entry.lastActiveAt || 0) > POOL_IDLE_MS) { rememberLog(`Reaping idle profile backend "${profile}" (idle > ${Math.round(POOL_IDLE_MS / 1000)}s)`) stopPoolBackend(profile) } } + if (backendPool.size === 0 && poolIdleReaper) { clearInterval(poolIdleReaper) poolIdleReaper = null } }, 60_000) - if (typeof poolIdleReaper.unref === 'function') poolIdleReaper.unref() + + if (typeof poolIdleReaper.unref === 'function') { + poolIdleReaper.unref() + } } // Spawn an additional dashboard backend pinned to a named profile. Mirrors the @@ -5342,8 +6519,10 @@ async function spawnPoolBackend(profile, entry) { // entry keeps `entry.process === null`, which stopPoolBackend/evict already // tolerate. const remote = await resolveRemoteBackend(profile) + if (remote) { await waitForHermes(remote.baseUrl, remote.token) + return { ...remote, profile, @@ -5390,6 +6569,7 @@ async function spawnPoolBackend(profile, entry) { stdio: ['ignore', 'pipe', 'pipe'] }) ) + entry.process = child entry.token = token @@ -5398,9 +6578,11 @@ async function spawnPoolBackend(profile, entry) { let ready = false let rejectStart = null + const startFailed = new Promise((_resolve, reject) => { rejectStart = reject }) + child.once('error', error => { rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`) backendPool.delete(profile) @@ -5409,6 +6591,7 @@ async function spawnPoolBackend(profile, entry) { child.once('exit', (code, signal) => { rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`) backendPool.delete(profile) + if (!ready) { rejectStart?.( new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`) @@ -5418,19 +6601,23 @@ async function spawnPoolBackend(profile, entry) { // Discover the ephemeral port the child bound to const port = await Promise.race([waitForDashboardPortAnnouncement(child, { readyFile }), startFailed]) + if (readyFile) { fs.unlink(readyFile, () => {}) } + entry.port = port const baseUrl = `http://127.0.0.1:${port}` await Promise.race([waitForHermes(baseUrl, token), startFailed]) ready = true + const authToken = await adoptServedDashboardToken(baseUrl, token, { childAlive: () => child.exitCode === null && !child.killed, label: `Hermes backend for profile "${profile}"`, rememberLog }) + entry.token = authToken return { @@ -5448,14 +6635,22 @@ async function spawnPoolBackend(profile, entry) { function stopPoolBackend(profile) { const entry = backendPool.get(profile) - if (!entry) return + + if (!entry) { + return + } + backendPool.delete(profile) stopBackendChild(entry.process) } async function teardownPoolBackendAndWait(profile) { const entry = backendPool.get(profile) - if (!entry) return + + if (!entry) { + return + } + backendPool.delete(profile) stopBackendChild(entry.process) @@ -5469,51 +6664,38 @@ function stopAllPoolBackends() { } } -function profileNameFromDeleteRequest(request) { - if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') { - return null - } - - const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/) - if (!match) { - return null - } - - let raw = '' - try { - raw = decodeURIComponent(match[1]) - } catch { - return null - } - - const name = raw.trim() - if (!name) { - return null - } - if (name.toLowerCase() === 'default') { - return 'default' - } - return name.toLowerCase() -} - // Returns the profile name whose backend was torn down, or null when the // request is not a profile-delete. The caller uses this to skip ensureBackend // for the just-torn-down profile — otherwise ensureBackend respawns a pool // backend whose ensure_hermes_home() recreates the deleted profile directory. +// +// The routing *decision* (which branch fires, what profile name gets +// returned) lives in the pure decideProfileDeleteAction() in +// profile-delete-routing.ts; this function only performs the side effects +// that decision calls for. async function prepareProfileDeleteRequest(request) { const profile = profileNameFromDeleteRequest(request) - if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) { + + const decision = decideProfileDeleteAction(profile, { + isDefaultProfile: p => p === 'default', + isValidProfileName: p => PROFILE_NAME_RE.test(p), + primaryProfileKey + }) + + if (decision.action === 'noop') { return null } - if (profile === primaryProfileKey()) { + if (decision.action === 'teardown-primary') { writeActiveDesktopProfile('default') await teardownPrimaryBackendAndWait() - return profile + + return decision.profile } - await teardownPoolBackendAndWait(profile) - return profile + await teardownPoolBackendAndWait(decision.profile) + + return decision.profile } async function startHermes() { @@ -5526,16 +6708,21 @@ async function startHermes() { if (bootstrapFailure) { throw bootstrapFailure } + if (backendStartFailure) { throw backendStartFailure } - if (connectionPromise) return connectionPromise + + if (connectionPromise) { + return connectionPromise + } connectionPromise = (async () => { await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) // Resolve for the desktop's primary profile so a per-profile remote // override on the active profile is honored (falls back to env / global). const remote = await resolveRemoteBackend(primaryProfileKey()) + if (remote) { await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24) await waitForHermes(remote.baseUrl, remote.token) @@ -5546,6 +6733,7 @@ async function startHermes() { running: true, error: null }) + return { baseUrl: remote.baseUrl, mode: 'remote', @@ -5575,9 +6763,11 @@ async function startHermes() { // unset preference keeps the legacy launch so existing installs are // unaffected. const activeProfile = readActiveDesktopProfile() + if (activeProfile) { backendArgs.unshift('--profile', activeProfile) } + await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) const backend = await ensureRuntime(resolveHermesBackend(backendArgs)) // Route old runtimes (no `serve`) through the legacy `dashboard --no-open`. @@ -5623,9 +6813,11 @@ async function startHermes() { hermesProcess.stderr.on('data', rememberLog) let backendReady = false let rejectBackendStart = null + const backendStartFailed = new Promise((_resolve, reject) => { rejectBackendStart = reject }) + hermesProcess.once('error', error => { rememberLog(`Hermes backend failed to start: ${error.message}`) updateBootProgress( @@ -5647,6 +6839,7 @@ async function startHermes() { hermesProcess = null connectionPromise = null sendBackendExit({ code, signal }) + if (!backendReady) { const message = `Hermes backend exited before it became ready (${signal || code}).` updateBootProgress( @@ -5667,11 +6860,13 @@ async function startHermes() { }) await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86) + // Discover the ephemeral port the child bound to const port = await Promise.race([ waitForDashboardPortAnnouncement(hermesProcess, { readyFile }), backendStartFailed ]) + if (readyFile) { fs.unlink(readyFile, () => {}) } @@ -5681,11 +6876,13 @@ async function startHermes() { await Promise.race([waitForHermes(baseUrl, token), backendStartFailed]) backendReady = true backendStartFailure = null + const authToken = await adoptServedDashboardToken(baseUrl, token, { // The exit/error handlers null hermesProcess when the child dies. childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed, rememberLog }) + updateBootProgress({ phase: 'backend.ready', message: 'Hermes backend is ready. Finalizing desktop startup', @@ -5729,10 +6926,21 @@ async function startHermes() { // security posture: external links open in the OS browser, in-app navigation // stays confined to the dev server / packaged file URL, and the preview / // devtools / zoom / context-menu affordances behave identically everywhere. -function wireCommonWindowHandlers(win) { +// +// `zoom` is opt-out for the pet overlay: it sizes its own OS window to fit the +// sprite in unzoomed CSS px (overlayWindowSize -> setBounds) and has its own +// Alt+wheel scale, so inheriting the global UI zoom would render the mascot +// larger than its window and crop it. Chat windows keep zoom on. +function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) { installPreviewShortcut(win) installDevToolsShortcut(win) - installZoomShortcuts(win) + if (zoom) { + installZoomShortcuts(win) + // Re-apply persisted zoom on show/restore (Windows drops webContents zoom on + // minimize/restore) and on first load (reloads / crash recovery). + installZoomReassertOnWindowEvents(win, () => restorePersistedZoomLevel(win)) + win.webContents.once('did-finish-load', () => restorePersistedZoomLevel(win)) + } installContextMenu(win) win.webContents.setWindowOpenHandler(details => { openExternalUrl(details.url) @@ -5753,18 +6961,32 @@ function wireCommonWindowHandlers(win) { // work with multiple chats side by side. The registry guarantees one window // per sessionId (re-opening focuses the existing window) and self-cleans on // close. The primary mainWindow is never tracked here. Pure logic + the URL -// builder live in session-windows.cjs so they stay unit-testable. +// builder live in session-windows.ts so they stay unit-testable. const sessionWindows = createSessionWindowRegistry() function focusWindow(win) { - if (!win || win.isDestroyed()) return - if (win.isMinimized()) win.restore() - if (!win.isVisible()) win.show() + if (!win || win.isDestroyed()) { + return + } + + if (win.isMinimized()) { + win.restore() + } + + if (!win.isVisible()) { + win.show() + } + win.focus() } -function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { +function spawnSecondaryWindow({ + sessionId, + watch, + newSession +}: { sessionId?: string; watch?: boolean; newSession?: boolean } = {}) { const icon = getAppIconPath() + const win = new BrowserWindow({ width: SESSION_WINDOW_MIN_WIDTH, height: SESSION_WINDOW_MIN_HEIGHT, @@ -5785,7 +7007,7 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { // themes/context.tsx, so the window appears already themed. show: false, backgroundColor: getWindowBackgroundColor(), - webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs')) + webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) if (IS_MAC) { @@ -5793,15 +7015,15 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { } win.once('ready-to-show', () => { - if (!win.isDestroyed()) win.show() + if (!win.isDestroyed()) { + win.show() + } }) - win.on('will-enter-full-screen', () => sendWindowStateChanged(true)) win.on('enter-full-screen', () => sendWindowStateChanged(true)) - win.on('will-leave-full-screen', () => sendWindowStateChanged(false)) win.on('leave-full-screen', () => sendWindowStateChanged(false)) - wireCommonWindowHandlers(win) + wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) win.loadURL( buildSessionWindowUrl(sessionId, { @@ -5879,7 +7101,7 @@ function spawnPetOverlayWindow(bounds) { // Fully transparent — the renderer paints only the sprite + bubble. backgroundColor: '#00000000', webPreferences: { - preload: path.join(__dirname, 'preload.cjs'), + preload: PRELOAD_PATH, contextIsolation: true, sandbox: true, nodeIntegration: false, @@ -5896,6 +7118,7 @@ function spawnPetOverlayWindow(bounds) { // switching semantics. win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver') win.setHiddenInMissionControl?.(true) + try { // Electron docs: macOS may transform process type on each // setVisibleOnAllWorkspaces() call unless skipTransformProcessType=true, @@ -5910,10 +7133,14 @@ function spawnPetOverlayWindow(bounds) { // Not supported everywhere — best effort. } - wireCommonWindowHandlers(win) + // Pet overlay opts out of global UI zoom (see zoomWiringForWindowKind): it + // owns its window-fit + scale, and inheriting zoom would crop the sprite. + wireCommonWindowHandlers(win, zoomWiringForWindowKind('petOverlay')) win.once('ready-to-show', () => { - if (!win.isDestroyed()) win.showInactive() + if (!win.isDestroyed()) { + win.showInactive() + } }) win.on('closed', () => { @@ -5991,12 +7218,13 @@ function createWindow() { // Shared with the secondary session windows (chatWindowWebPreferences) so // both keep `backgroundThrottling: false` — the chat transcript streams via // a requestAnimationFrame-gated flush that Chromium pauses for blurred - // windows, stalling the live answer until refocus. See session-windows.cjs. - webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs')) + // windows, stalling the live answer until refocus. See session-windows.ts. + webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) if (IS_MAC) { mainWindow.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) + if (icon) { app.dock?.setIcon(icon) } @@ -6011,10 +7239,14 @@ function createWindow() { } } - if (savedWindowState?.isMaximized) mainWindow.maximize() + if (savedWindowState?.isMaximized) { + mainWindow.maximize() + } mainWindow.once('ready-to-show', () => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show() + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show() + } }) mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true)) @@ -6035,7 +7267,7 @@ function createWindow() { // window-all-closed from quitting on Windows/Linux). mainWindow.on('closed', () => closePetOverlay()) - wireCommonWindowHandlers(mainWindow) + wireCommonWindowHandlers(mainWindow, zoomWiringForWindowKind('chat')) mainWindow.webContents.on('render-process-gone', (_event, details) => { rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`) @@ -6054,7 +7286,10 @@ function createWindow() { rendererReloadTimes.push(now) setImmediate(() => { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + try { mainWindow.webContents.reload() } catch (err) { @@ -6074,7 +7309,9 @@ function createWindow() { const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null const level = details ? details.level : detailsOrLevel - if (level !== 3) return + if (level !== 3) { + return + } const text = details ? details.message : message const src = details ? details.sourceUrl : sourceId @@ -6089,7 +7326,8 @@ function createWindow() { } mainWindow.webContents.once('did-finish-load', () => { - restorePersistedZoomLevel(mainWindow) + // Zoom restore is handled by wireCommonWindowHandlers (shared with session + // windows); no need to reapply it here. broadcastBootProgress() sendWindowStateChanged() startHermes().catch(error => rememberLog(error.stack || error.message)) @@ -6111,6 +7349,7 @@ ipcMain.handle('hermes:connection:revalidate', async () => { } let conn = null + try { conn = await connectionPromise } catch { @@ -6124,8 +7363,10 @@ ipcMain.handle('hermes:connection:revalidate', async () => { } const base = conn.baseUrl.replace(/\/+$/, '') + try { await fetchPublicJson(`${base}/api/status`, { timeoutMs: 2_500 }) + return { ok: true, rebuilt: false } } catch { // Unreachable remote: drop the stale cache so the renderer's next reconnect @@ -6133,11 +7374,13 @@ ipcMain.handle('hermes:connection:revalidate', async () => { // nulls connectionPromise for a remote (no child to SIGTERM). rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.') resetHermesConnection() + return { ok: true, rebuilt: true } } }) ipcMain.handle('hermes:backend:touch', async (_event, profile) => { touchPoolBackend(profile) + return { ok: true } }) ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile)) @@ -6167,7 +7410,11 @@ ipcMain.handle('hermes:zoom:get', event => { }) ipcMain.on('hermes:zoom:set-percent', (event, percent) => { const window = BrowserWindow.fromWebContents(event.sender) - if (!window || window.isDestroyed()) return + + if (!window || window.isDestroyed()) { + return + } + setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent))) }) @@ -6250,6 +7497,7 @@ ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { } petOverlayWindow.setFocusable(Boolean(focusable)) + if (focusable) { petOverlayWindow.focus() } @@ -6311,6 +7559,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { completedAt: null, unsupportedPlatform: null } + return { ok: true } }) ipcMain.handle('hermes:bootstrap:repair', async () => { @@ -6319,6 +7568,7 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { // venv), and clear any latched failure + live connection. The renderer // reloads afterwards to re-drive the boot flow from scratch. rememberLog('[bootstrap] repair requested by renderer; clearing marker + latched failure') + try { if (fileExists(BOOTSTRAP_COMPLETE_MARKER)) { fs.rmSync(BOOTSTRAP_COMPLETE_MARKER, { force: true }) @@ -6326,9 +7576,11 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { } catch (error) { rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`) } + bootstrapFailure = null backendStartFailure = null resetHermesConnection() + return { ok: true } }) ipcMain.handle('hermes:bootstrap:cancel', async () => { @@ -6341,8 +7593,10 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => { } catch { void 0 } + return { ok: true, cancelled: true } } + return { ok: false, cancelled: false } }) ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState) @@ -6359,16 +7613,47 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => // the URL defensively so a login can be driven from a raw URL too. const baseUrl = normalizeRemoteBaseUrl(rawUrl) await openOauthLoginWindow(baseUrl) + return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) } }) ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => { const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : '' await clearOauthSession(baseUrl || undefined) + // Report against the SAME liveness notion the Settings indicator uses // (AT-or-RT) so a logout that left any session cookie behind is reflected // as still-connected rather than silently signed-out. return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false } }) + +// --- Hermes Cloud (cloud-auto-discovery Phase 3) --- +// One portal login in the OAuth partition powers both discovery and the silent +// per-agent cascade. See the discovery/cascade helpers above. +ipcMain.handle('hermes:cloud:status', async () => ({ + portalBaseUrl: resolvePortalBaseUrl(), + signedIn: await hasLivePortalSession() +})) +ipcMain.handle('hermes:cloud:login', async () => { + await openPortalLoginWindow() + + return { ok: true, signedIn: await hasLivePortalSession() } +}) +ipcMain.handle('hermes:cloud:logout', async () => { + await clearOauthSession(resolvePortalBaseUrl()) + + return { ok: true, signedIn: await hasLivePortalSession() } +}) +ipcMain.handle('hermes:cloud:discover', async (_event, org) => { + // Returns { agents } or { needsOrgSelection: true, orgs }. `org` (optional) + // scopes discovery to a chosen org for multi-org users. + return discoverCloudAgents(typeof org === 'string' && org ? org : undefined) +}) +ipcMain.handle('hermes:cloud:agent-sign-in', async (_event, dashboardUrl) => { + // Silent per-agent sign-in via the shared portal session. Returns the agent's + // gateway baseUrl + whether its session cookie landed; the renderer then + // saves a cloud-mode connection pointed at this dashboardUrl. + return cloudAgentSilentSignIn(dashboardUrl) +}) ipcMain.handle('hermes:connection-config:save', async (_event, payload) => { const config = coerceDesktopConnectionConfig(payload) writeDesktopConnectionConfig(config) @@ -6387,10 +7672,11 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => { // re-resolves against the new remote/local target. stopPoolBackend(key) } else { - // Global connection, or the primary profile's connection: re-home the - // window backend by tearing it down and reloading the renderer. - await teardownPrimaryBackendAndWait() - mainWindow?.reload() + // Global / primary connection: soft re-home. Tear down the window backend + // without resetting boot UI or reloading — the shell stays, the renderer + // wipes session lists (skeletons) and re-dials on hermes:connection:applied. + await teardownPrimaryBackendAndWait({ soft: true }) + sendConnectionApplied() } return sanitizeDesktopConnectionConfig(config, payload?.profile) @@ -6434,25 +7720,32 @@ async function interceptSessionRequestForRemote(request) { if (typeof request?.path !== 'string') { return undefined } + const method = (request.method || 'GET').toUpperCase() let parsed + try { parsed = new URL(request.path, 'http://x') } catch { return undefined } + const { pathname, searchParams } = parsed if (method === 'GET' && pathname === '/api/profiles/sessions') { const remoteProfiles = configuredRemoteProfileNames() + if (remoteProfiles.length === 0) { return undefined // no remote profiles → local fast path } + const requested = (searchParams.get('profile') || 'all').trim() || 'all' + if (requested !== 'all') { return profileHasRemoteOverride(requested) ? remoteSessionList(requested, searchParams) : undefined } + return mergeRemoteProfileSessions(searchParams, remoteProfiles) } @@ -6464,27 +7757,39 @@ async function interceptSessionRequestForRemote(request) { // route there and KEEP the profile param so it opens the right state.db. if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) { const profile = (searchParams.get('profile') || request.profile || '').trim() + if (!profile) { return undefined } + if (profileHasRemoteOverride(profile)) { if (method === 'GET') { return fetchJsonForProfile(profile, pathname) } + const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body - if (body) delete body.profile + + if (body) { + delete body.profile + } + return requestJsonForProfile(profile, pathname, method, body) } + if (globalRemoteActive()) { // Single global backend: keep ?profile= so it opens the right state.db. const sep = pathname.includes('?') ? '&' : '?' const path = `${pathname}${sep}profile=${encodeURIComponent(profile)}` + if (method === 'GET') { return fetchJsonForProfile(null, path) } + const body = request.body && typeof request.body === 'object' ? { ...request.body, profile } : { profile } + return requestJsonForProfile(null, path, method, body) } + return undefined } @@ -6499,11 +7804,13 @@ async function remoteSessionList(profile, searchParams) { const qs = new URLSearchParams(searchParams) qs.delete('profile') // remote serves its own db; no cross-profile read there const data = await fetchJsonForProfile(profile, `/api/sessions?${qs}`) + for (const s of rowsOf(data)) { s.profile = profile s.is_default_profile = false } - return { ...data, sessions: rowsOf(data) } + + return { ...(data as any), sessions: rowsOf(data) } } // Unified list: primary's local aggregate, with each remote profile's stale local @@ -6516,10 +7823,11 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active' const primary = await ensureBackend(null) - const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, { + + const base = (await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, { method: 'GET', timeoutMs: DEFAULT_FETCH_TIMEOUT_MS - }).catch(() => ({ sessions: [], total: 0, profile_totals: {} })) + }).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))) as any // Over-fetch each remote from offset 0 (limit+offset rows) so the merged window // is correct for this page — mirrors the primary's per-profile over-fetch. @@ -6536,10 +7844,13 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { await Promise.all( remoteProfiles.map(async name => { const list = await remoteSessionList(name, remoteParams).catch(() => null) + if (!list) { delete profileTotals[name] // dead remote → drop its stale local total too + return } + const rows = rowsOf(list) merged.push(...rows) profileTotals[name] = Number(list.total) || rows.length @@ -6549,7 +7860,8 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { const recency = s => s?.[order] ?? s?.started_at ?? 0 merged.sort((a, b) => recency(b) - recency(a)) - return { ...base, sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals } + + return { ...(base as any), sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals } } ipcMain.handle('hermes:api', async (_event, request) => { @@ -6558,6 +7870,7 @@ ipcMain.handle('hermes:api', async (_event, request) => { // profile's sessions live on its remote host, so the UI's IDs 404 (or mutations // no-op) the moment they run there. Route reads + mutations to the remote. const rerouted = await interceptSessionRequestForRemote(request) + if (rerouted !== undefined) { return rerouted } @@ -6569,58 +7882,83 @@ ipcMain.handle('hermes:api', async (_event, request) => { // backend instead of spawning a fresh pool backend. A freshly spawned // backend calls ensure_hermes_home() which recreates the profile directory, // defeating the deletion and leaving a zombie process. - const routeProfile = tornDownProfile ? null : profile + const routeProfile = resolveRouteProfile(tornDownProfile, profile) const connection = await ensureBackend(routeProfile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) + const requestPath = pathWithGlobalRemoteProfile(request.path, profile, { globalRemote: globalRemoteActive(), profileRemoteOverride: profileHasRemoteOverride(profile) }) + const url = `${connection.baseUrl}${requestPath}` + // OAuth gateways authenticate REST via the HttpOnly session cookie held in // the OAuth partition — route through Electron's net stack bound to that // session so the cookie attaches automatically. Token/local modes keep using // the static session-token header. if (connection.authMode === 'oauth') { + // The OAuth path rides electron.net with JSON headers; multipart isn't + // wired there. Fail loudly rather than corrupting the upload. + if (request?.upload) { + throw new Error('File uploads are not supported against OAuth-gated remote backends yet.') + } + return fetchJsonViaOauthSession(url, { method: request?.method, body: request?.body, timeoutMs }) } + return fetchJson(url, connection.token, { method: request?.method, body: request?.body, + upload: request?.upload, timeoutMs }) }) ipcMain.handle('hermes:notify', (_event, payload) => { - if (!Notification.isSupported()) return false + if (!Notification.isSupported()) { + return false + } + // Action buttons render only on signed macOS builds; elsewhere they're dropped // and the body click still works. const actions = Array.isArray(payload?.actions) ? payload.actions : [] + const notification = new Notification({ title: payload?.title || 'Hermes', body: payload?.body || '', silent: Boolean(payload?.silent), actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') })) }) + notification.on('click', () => { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + focusWindow(mainWindow) + if (payload?.sessionId) { mainWindow.webContents.send('hermes:focus-session', payload.sessionId) } }) notification.on('action', (_actionEvent, index) => { - if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + const action = actions[index] + if (action?.id) { mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id }) } }) notification.show() + return true }) @@ -6629,7 +7967,9 @@ ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => { maxBytes: DATA_URL_READ_MAX_BYTES, purpose: 'File preview' }) + const data = await fs.promises.readFile(resolvedPath) + return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}` }) @@ -6638,6 +7978,7 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => { maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES, purpose: 'Text preview' }) + const ext = path.extname(resolvedPath).toLowerCase() const handle = await fs.promises.open(resolvedPath, 'r') const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES) @@ -6660,14 +8001,21 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => { } }) -ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => { +ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => { const properties = options?.directories ? ['openDirectory'] : ['openFile'] - if (options?.multiple !== false) properties.push('multiSelections') + + if (options?.multiple !== false) { + properties.push('multiSelections') + } let resolvedDefaultPath + if (options?.defaultPath) { try { - resolvedDefaultPath = path.resolve(String(options.defaultPath)) + // On a Windows host with a WSL backend the cwd may be a POSIX/WSL path; + // bridge it to a UNC/drive form the native dialog can actually open. + const bridged = IS_WINDOWS ? resolvePickerDefaultPath(String(options.defaultPath)) : String(options.defaultPath) + resolvedDefaultPath = bridged ? path.resolve(bridged) : undefined } catch { resolvedDefaultPath = undefined } @@ -6676,16 +8024,20 @@ ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => { const result = await dialog.showOpenDialog(mainWindow, { title: options?.title || 'Add context', defaultPath: resolvedDefaultPath, - properties, + properties: properties as any, filters: Array.isArray(options?.filters) ? options.filters : undefined }) - if (result.canceled) return [] + if (result.canceled) { + return [] + } + return result.filePaths }) ipcMain.handle('hermes:writeClipboard', (_event, text) => { clipboard.writeText(String(text || '')) + return true }) @@ -6693,14 +8045,19 @@ ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(Stri ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => { const data = payload?.data - if (!data) throw new Error('saveImageBuffer: missing data') + + if (!data) { + throw new Error('saveImageBuffer: missing data') + } const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data) + return writeComposerImage(buffer, payload?.ext || '.png') }) ipcMain.handle('hermes:saveClipboardImage', async () => { const image = clipboard.readImage() + if (image && !image.isEmpty()) { return writeComposerImage(image.toPNG(), '.png') } @@ -6710,6 +8067,7 @@ ipcMain.handle('hermes:saveClipboardImage', async () => { // Pull it straight off the Windows clipboard via PowerShell as a fallback. if (IS_WSL) { const png = readWslWindowsClipboardImage() + if (png) { return writeComposerImage(png, '.png') } @@ -6826,10 +8184,13 @@ ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url)) ipcMain.handle('hermes:logs:reveal', async () => { try { await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) + if (!fileExists(DESKTOP_LOG_PATH)) { await fs.promises.appendFile(DESKTOP_LOG_PATH, '') } + shell.showItemInFolder(DESKTOP_LOG_PATH) + return { ok: true, path: DESKTOP_LOG_PATH } } catch (error) { return { ok: false, path: DESKTOP_LOG_PATH, error: error.message } @@ -6845,6 +8206,7 @@ function isExecutableFile(filePath) { try { fs.accessSync(filePath, fs.constants.X_OK) + return true } catch { return false @@ -6858,39 +8220,6 @@ function posixShellSpec(shellPath) { return { args: interactiveArgs, command: shellPath, name: shellName } } -let spawnHelperChecked = false - -// node-pty execs a `spawn-helper` binary on macOS/Linux to launch the shell in a -// fresh session. The prebuilt that ships in node-pty's `prebuilds/` (and the -// staged copy under resources/native-deps) loses its execute bit through npm -// pack / electron-builder file collection, so every nodePty.spawn() dies with -// "posix_spawnp failed". Restore +x once, lazily, before the first spawn. -function ensureSpawnHelperExecutable() { - if (spawnHelperChecked || IS_WINDOWS || !nodePtyDir) { - return - } - - spawnHelperChecked = true - - const arch = process.arch - const candidates = [ - path.join(nodePtyDir, 'build', 'Release', 'spawn-helper'), - path.join(nodePtyDir, 'prebuilds', `${process.platform}-${arch}`, 'spawn-helper') - ] - - for (const helper of candidates) { - try { - const mode = fs.statSync(helper).mode - - if ((mode & 0o111) !== 0o111) { - fs.chmodSync(helper, mode | 0o755) - } - } catch { - // Not present in this layout (e.g. compiled build vs prebuild); skip. - } - } -} - // Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box; // prefer it only after PowerShell 7+ (`pwsh`). function windowsPowerShellPath() { @@ -7002,6 +8331,51 @@ function terminalChannel(id, suffix) { return `hermes:terminal:${id}:${suffix}` } +// Best-effort read of a live PTY child's current working directory so a +// reopened tab can restart the shell where the user last `cd`'d, instead of the +// tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on +// POSIX; Windows has no cheap per-process cwd query without a native module, so +// it returns null and the caller falls back to the launch cwd. +function readProcessCwd(pid) { + return new Promise(resolve => { + if (!Number.isInteger(pid) || pid <= 0) { + resolve(null) + + return + } + + if (process.platform === 'linux') { + fs.promises + .readlink(`/proc/${pid}/cwd`) + .then(target => resolve(target || null)) + .catch(() => resolve(null)) + + return + } + + if (process.platform === 'darwin') { + // lsof ships with macOS; -Fn emits the cwd fd's path on an `n<path>` line. + execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => { + if (err) { + resolve(null) + + return + } + + const line = String(stdout || '') + .split('\n') + .find(entry => entry.startsWith('n')) + + resolve(line ? line.slice(1) : null) + }) + + return + } + + resolve(null) + }) +} + function disposeTerminalSession(id) { const sessionInfo = terminalSessions.get(id) @@ -7041,6 +8415,28 @@ ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => { } }) +// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike +// `reveal` (which selects an existing item and silently no-ops on a missing +// path — the "Open plugins folder" Windows bug), this is for the plugins door, +// which often doesn't exist on first use. `shell.openPath` returns '' on +// success or an error string; both mkdir + openPath failures are surfaced. +ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => { + const dir = String(dirPath || '').trim() + + if (!dir) { + return { ok: false, error: 'no path' } + } + + try { + await fs.promises.mkdir(dir, { recursive: true }) + const error = await shell.openPath(path.normalize(dir)) + + return error ? { ok: false, error } : { ok: true } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } +}) + // Rename a file/folder in place. The renderer passes the existing path + a new // base name; the destination is resolved in the SAME parent dir so a rename can // never move the item elsewhere or traverse out. Rejects on a name collision. @@ -7127,6 +8523,10 @@ ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) => ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary())) +ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) => + listBaseBranches(repoPath, resolveGitBinary()) +) + // Compact repo status (branch, ahead/behind, change counts + files) for the // composer coding rail. Returns null on a non-repo / remote backend so the rail // hides cleanly rather than erroring. @@ -7180,17 +8580,12 @@ ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { }) ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { - if (!nodePty) { - throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.') - } - - ensureSpawnHelperExecutable() - const id = crypto.randomUUID() const { args, command, name } = terminalShellCommand() const cwd = safeTerminalCwd(payload?.cwd) const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) + const ptyProcess = nodePty.spawn(command, args, { cols, cwd, @@ -7245,6 +8640,16 @@ ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { return true }) +ipcMain.handle('hermes:terminal:cwd', async (_event, id) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return null + } + + return readProcessCwd(sessionInfo.pty.pid) +}) + ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) ipcMain.handle('hermes:updates:check', async () => @@ -7270,6 +8675,7 @@ ipcMain.handle('hermes:updates:branch:get', async () => readDesktopUpdateConfig( ipcMain.handle('hermes:updates:branch:set', async (_event, name) => { const branch = typeof name === 'string' && name.trim() ? name.trim() : DEFAULT_UPDATE_BRANCH writeDesktopUpdateConfig({ branch }) + return { branch } }) @@ -7282,9 +8688,11 @@ function resolveHermesVersion() { try { const root = resolveUpdateRoot() const initPath = path.join(root, 'hermes_cli', '__init__.py') + if (fileExists(initPath)) { const raw = fs.readFileSync(initPath, 'utf8') const match = raw.match(/__version__\s*=\s*["']([^"']+)["']/) + if (match) { return match[1] } @@ -7292,6 +8700,7 @@ function resolveHermesVersion() { } catch { // Fall through to the Electron app version below. } + return app.getVersion() } @@ -7338,6 +8747,7 @@ function uninstallVenvPython() { async function getUninstallSummary() { const py = uninstallVenvPython() const agentRoot = ACTIVE_HERMES_ROOT + // Fast JS-side fallback used when the agent venv is gone (lite client) or the // probe fails — the renderer still needs *something* to render options from. const fallback = () => ({ @@ -7359,11 +8769,16 @@ async function getUninstallSummary() { return new Promise(resolve => { let stdout = '' let settled = false + const done = value => { - if (settled) return + if (settled) { + return + } + settled = true resolve(value) } + try { const child = spawn( py, @@ -7374,12 +8789,16 @@ async function getUninstallSummary() { stdio: ['ignore', 'pipe', 'ignore'] }) ) + child.stdout.on('data', chunk => { stdout += chunk.toString() }) child.on('error', () => done(fallback())) child.on('exit', code => { - if (code !== 0) return done(fallback()) + if (code !== 0) { + return done(fallback()) + } + try { const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}' const parsed = JSON.parse(line) @@ -7401,6 +8820,7 @@ async function getUninstallSummary() { async function runDesktopUninstall(mode) { let uninstallArgs + try { uninstallArgs = uninstallArgsForMode(mode) } catch (error) { @@ -7408,6 +8828,7 @@ async function runDesktopUninstall(mode) { } const venvPy = uninstallVenvPython() + if (!fileExists(venvPy)) { return { ok: false, @@ -7426,8 +8847,10 @@ async function runDesktopUninstall(mode) { // leave venv remnants the user can delete, which we log. let py = venvPy let pythonPath = null + if (modeRemovesAgent(mode)) { const sysPy = findSystemPython() + if (sysPy) { py = sysPy pythonPath = ACTIVE_HERMES_ROOT @@ -7468,6 +8891,7 @@ async function runDesktopUninstall(mode) { let scriptPath let runner let runnerArgs + try { if (IS_WINDOWS) { scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.cmd`) @@ -7490,6 +8914,7 @@ async function runDesktopUninstall(mode) { stdio: 'ignore', windowsHide: true }) + child.unref() } catch (error) { return { ok: false, error: 'spawn-failed', message: error.message } @@ -7504,12 +8929,14 @@ async function runDesktopUninstall(mode) { // the venv python shim + app bundle unlock and the cleanup script can run. isQuittingForHandoff = true setTimeout(() => app.quit(), 800) + return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath } } ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary()) ipcMain.handle('hermes:uninstall:run', async (_event, payload) => { const mode = payload && typeof payload === 'object' ? payload.mode : payload + return runDesktopUninstall(String(mode || '')) }) @@ -7531,19 +8958,28 @@ let _pendingDeepLink = null let _rendererReadyForDeepLink = false function _extractDeepLink(argv) { - if (!Array.isArray(argv)) return null + if (!Array.isArray(argv)) { + return null + } + return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null } function handleDeepLink(url) { - if (!url || typeof url !== 'string') return + if (!url || typeof url !== 'string') { + return + } + let parsed + try { parsed = new URL(url) } catch { rememberLog(`[deeplink] ignoring malformed url: ${url}`) + return } + // hermes://blueprint/<key>?slot=val -> host="blueprint", path="/<key>" const kind = parsed.hostname || '' const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, '')) @@ -7555,10 +8991,15 @@ function handleDeepLink(url) { if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) { _pendingDeepLink = payload + return } + try { - if (mainWindow.isMinimized()) mainWindow.restore() + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + mainWindow.focus() mainWindow.webContents.send('hermes:deep-link', payload) rememberLog(`[deeplink] delivered ${kind}/${name}`) @@ -7571,6 +9012,7 @@ function handleDeepLink(url) { // a link that arrived during boot/install is flushed exactly once. ipcMain.handle('hermes:deep-link-ready', () => { _rendererReadyForDeepLink = true + if (_pendingDeepLink) { const queued = _pendingDeepLink _pendingDeepLink = null @@ -7579,6 +9021,7 @@ ipcMain.handle('hermes:deep-link-ready', () => { (Object.keys(queued.params).length ? '?' + new URLSearchParams(queued.params).toString() : '') ) } + return { ok: true } }) @@ -7600,14 +9043,20 @@ function registerDeepLinkProtocol() { // second-instance argv. Without the lock a second `hermes://` launch spawns a // whole new app instead of routing into the running one. const _gotSingleInstanceLock = app.requestSingleInstanceLock() + if (!_gotSingleInstanceLock) { app.quit() } else { app.on('second-instance', (_event, argv) => { const url = _extractDeepLink(argv) - if (url) handleDeepLink(url) - else if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore() + + if (url) { + handleDeepLink(url) + } else if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + mainWindow.focus() } }) @@ -7626,6 +9075,7 @@ app.whenReady().then(() => { } else { Menu.setApplicationMenu(null) } + installMediaPermissions() registerMediaProtocol() installEmbedReferer() @@ -7637,7 +9087,10 @@ app.whenReady().then(() => { // Win/Linux cold start: the launching hermes:// URL is in our own argv. const _coldStartLink = _extractDeepLink(process.argv) - if (_coldStartLink) handleDeepLink(_coldStartLink) + + if (_coldStartLink) { + handleDeepLink(_coldStartLink) + } app.on('activate', () => { // Recreate the primary window if it's gone. Guard on mainWindow directly @@ -7692,6 +9145,7 @@ app.on('before-quit', () => { clearTimeout(desktopLogFlushTimer) desktopLogFlushTimer = null } + flushDesktopLogBufferSync() closePreviewWatchers() @@ -7712,5 +9166,7 @@ app.on('window-all-closed', () => { // the bundle and relaunch — without this the script's PID-wait spins to its // full timeout and the user is left with an invisible app (or an uninstall // that appears to do nothing). - if (process.platform !== 'darwin' || isQuittingForHandoff) app.quit() + if (process.platform !== 'darwin' || isQuittingForHandoff) { + app.quit() + } }) diff --git a/apps/desktop/electron/oauth-net-request.test.cjs b/apps/desktop/electron/oauth-net-request.test.ts similarity index 78% rename from apps/desktop/electron/oauth-net-request.test.cjs rename to apps/desktop/electron/oauth-net-request.test.ts index 63a27f6219ab..631119363f04 100644 --- a/apps/desktop/electron/oauth-net-request.test.cjs +++ b/apps/desktop/electron/oauth-net-request.test.ts @@ -1,13 +1,14 @@ /** * Tests for OAuth-session Electron net.request helpers. * - * Run with: node --test electron/oauth-net-request.test.cjs + * Run with: node --test electron/oauth-net-request.test.ts */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' -const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') +import { test } from 'vitest' + +import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' test('serializeJsonBody returns undefined for absent bodies', () => { assert.equal(serializeJsonBody(undefined), undefined) @@ -21,6 +22,7 @@ test('serializeJsonBody JSON-encodes request bodies', () => { test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => { const headers = [] + const request = { setHeader(name, value) { headers.push([name, value]) diff --git a/apps/desktop/electron/oauth-net-request.cjs b/apps/desktop/electron/oauth-net-request.ts similarity index 87% rename from apps/desktop/electron/oauth-net-request.cjs rename to apps/desktop/electron/oauth-net-request.ts index 0498a7333faf..bab5ef53f69d 100644 --- a/apps/desktop/electron/oauth-net-request.cjs +++ b/apps/desktop/electron/oauth-net-request.ts @@ -14,7 +14,4 @@ function setJsonRequestHeaders(request) { request.setHeader('Content-Type', 'application/json') } -module.exports = { - serializeJsonBody, - setJsonRequestHeaders -} +export { serializeJsonBody, setJsonRequestHeaders } diff --git a/apps/desktop/electron/oauth-session-request.test.cjs b/apps/desktop/electron/oauth-session-request.test.cjs deleted file mode 100644 index 3254318456b6..000000000000 --- a/apps/desktop/electron/oauth-session-request.test.cjs +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Regression coverage for the OAuth-session Electron net.request path. - * - * Electron net rejects manual Content-Length/Host headers with - * net::ERR_INVALID_ARGUMENT. Node HTTP helpers may still set Content-Length; - * this guard is scoped to fetchJsonViaOauthSession only. - */ - -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') - -const source = fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8') - -function extractFetchJsonViaOauthSession() { - const start = source.indexOf('function fetchJsonViaOauthSession') - const end = source.indexOf('// Mint a single-use WS ticket', start) - assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist') - assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist') - return source.slice(start, end) -} - -test('OAuth Electron net request does not set forbidden Content-Length header', () => { - const fn = extractFetchJsonViaOauthSession() - - assert.match(fn, /electronNet\.request/) - assert.doesNotMatch(fn, /setHeader\(['"]Content-Length['"]/) - assert.match(fn, /request\.write\(body\)/) -}) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.ts similarity index 91% rename from apps/desktop/electron/preload.cjs rename to apps/desktop/electron/preload.ts index 0a9c4fd79212..952fdfdc8dad 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.ts @@ -1,4 +1,4 @@ -const { contextBridge, ipcRenderer, webUtils } = require('electron') +import { contextBridge, ipcRenderer, webUtils } from 'electron' contextBridge.exposeInMainWorld('hermesDesktop', { getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), @@ -24,12 +24,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onState: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:pet-overlay:state', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener) }, // Main renderer subscribes to overlay control messages. onControl: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:pet-overlay:control', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener) } }, @@ -41,6 +43,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', { probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl), oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl), oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl), + // Hermes Cloud: one portal login powers discovery + silent per-agent sign-in + // (cloud-auto-discovery Phase 3). + cloud: { + status: () => ipcRenderer.invoke('hermes:cloud:status'), + login: () => ipcRenderer.invoke('hermes:cloud:login'), + logout: () => ipcRenderer.invoke('hermes:cloud:logout'), + discover: org => ipcRenderer.invoke('hermes:cloud:discover', org), + agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl) + }, profile: { get: () => ipcRenderer.invoke('hermes:profile:get'), set: name => ipcRenderer.invoke('hermes:profile:set', name) @@ -87,6 +98,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:zoom:changed', listener) + return () => ipcRenderer.removeListener('hermes:zoom:changed', listener) } }, @@ -95,6 +107,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath), gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath), revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath), + openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath), renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName), writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content), trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath), @@ -105,6 +118,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options), branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch), branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath), + baseBranchList: repoPath => ipcRenderer.invoke('hermes:git:baseBranchList', repoPath), repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath), fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath), scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options), @@ -124,6 +138,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { } }, terminal: { + cwd: id => ipcRenderer.invoke('hermes:terminal:cwd', id), dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id), resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size), start: options => ipcRenderer.invoke('hermes:terminal:start', options), @@ -132,68 +147,88 @@ contextBridge.exposeInMainWorld('hermesDesktop', { const channel = `hermes:terminal:${id}:data` const listener = (_event, payload) => callback(payload) ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) }, onExit: (id, callback) => { const channel = `hermes:terminal:${id}:exit` const listener = (_event, payload) => callback(payload) ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) } }, onClosePreviewRequested: callback => { const listener = () => callback() ipcRenderer.on('hermes:close-preview-requested', listener) + return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener) }, onOpenUpdatesRequested: callback => { const listener = () => callback() ipcRenderer.on('hermes:open-updates', listener) + return () => ipcRenderer.removeListener('hermes:open-updates', listener) }, onDeepLink: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:deep-link', listener) + return () => ipcRenderer.removeListener('hermes:deep-link', listener) }, signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'), onWindowStateChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:window-state-changed', listener) + return () => ipcRenderer.removeListener('hermes:window-state-changed', listener) }, onFocusSession: callback => { const listener = (_event, sessionId) => callback(sessionId) ipcRenderer.on('hermes:focus-session', listener) + return () => ipcRenderer.removeListener('hermes:focus-session', listener) }, onNotificationAction: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:notification-action', listener) + return () => ipcRenderer.removeListener('hermes:notification-action', listener) }, onPreviewFileChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:preview-file-changed', listener) + return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener) }, onBackendExit: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:backend-exit', listener) + return () => ipcRenderer.removeListener('hermes:backend-exit', listener) }, + // Soft gateway-mode apply finished tearing down the primary backend. Renderer + // should wipe session lists + re-dial without a window reload. + onConnectionApplied: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:connection:applied', listener) + + return () => ipcRenderer.removeListener('hermes:connection:applied', listener) + }, onPowerResume: callback => { const listener = () => callback() ipcRenderer.on('hermes:power-resume', listener) + return () => ipcRenderer.removeListener('hermes:power-resume', listener) }, onBootProgress: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:boot-progress', listener) + return () => ipcRenderer.removeListener('hermes:boot-progress', listener) }, // First-launch bootstrap progress -- emitted by the install.ps1 stage - // runner in main.cjs (apps/desktop/electron/bootstrap-runner.cjs). + // runner in main.ts (apps/desktop/electron/bootstrap-runner.ts). // Renderer's install overlay subscribes to live events and queries the // current snapshot via getBootstrapState() to recover after a devtools // reload mid-bootstrap. @@ -204,6 +239,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onBootstrapEvent: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:bootstrap:event', listener) + return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener) }, getVersion: () => ipcRenderer.invoke('hermes:version'), @@ -220,6 +256,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { onProgress: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:updates:progress', listener) + return () => ipcRenderer.removeListener('hermes:updates:progress', listener) } }, diff --git a/apps/desktop/electron/profile-delete-respawn.test.cjs b/apps/desktop/electron/profile-delete-respawn.test.cjs deleted file mode 100644 index 07e17f78749a..000000000000 --- a/apps/desktop/electron/profile-delete-respawn.test.cjs +++ /dev/null @@ -1,62 +0,0 @@ -'use strict' - -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') - -const ELECTRON_DIR = __dirname - -function readElectronFile(name) { - return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') -} - -// --------------------------------------------------------------------------- -// prepareProfileDeleteRequest must return the torn-down profile name so the -// caller can skip ensureBackend for that profile (issue #52279). -// --------------------------------------------------------------------------- - -test('prepareProfileDeleteRequest returns the torn-down profile name', () => { - const source = readElectronFile('main.cjs') - - // Locate the function definition and its closing brace. - const fnStart = source.indexOf('async function prepareProfileDeleteRequest(') - assert.notEqual(fnStart, -1, 'prepareProfileDeleteRequest function not found') - - // The function must contain "return profile" (pool and primary paths). - const fnBody = source.slice(fnStart, fnStart + 800) - const returnProfileCount = (fnBody.match(/return profile/g) || []).length - assert.ok( - returnProfileCount >= 2, - `expected at least 2 "return profile" statements (primary + pool paths), found ${returnProfileCount}` - ) - - // The early-exit guard must return null (not void/undefined). - assert.match(fnBody, /return null/, 'early-exit guard should return null, not undefined') -}) - -test('hermes:api handler routes profile-delete requests to the primary backend', () => { - const source = readElectronFile('main.cjs') - - // The handler must capture prepareProfileDeleteRequest's return value. - assert.match( - source, - /const tornDownProfile = await prepareProfileDeleteRequest\(request\)/, - 'handler should capture the return value of prepareProfileDeleteRequest' - ) - - // The handler must use the return value to skip ensureBackend for the - // torn-down profile, routing to the primary (null) instead. - assert.match( - source, - /const routeProfile = tornDownProfile \? null : profile/, - 'handler should route to primary backend when a profile was just torn down' - ) - - // ensureBackend must be called with the conditional route profile. - assert.match( - source, - /const connection = await ensureBackend\(routeProfile\)/, - 'handler should pass routeProfile (not raw profile) to ensureBackend' - ) -}) diff --git a/apps/desktop/electron/profile-delete-routing.test.ts b/apps/desktop/electron/profile-delete-routing.test.ts new file mode 100644 index 000000000000..6b1bb01da1e4 --- /dev/null +++ b/apps/desktop/electron/profile-delete-routing.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' + +// --------------------------------------------------------------------------- +// profileNameFromDeleteRequest +// --------------------------------------------------------------------------- + +test('profileNameFromDeleteRequest parses a DELETE /api/profiles/<name> path', () => { + assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/worker' }), 'worker') +}) + +test('profileNameFromDeleteRequest lowercases the profile name', () => { + assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/Worker' }), 'worker') +}) + +test('profileNameFromDeleteRequest returns null for non-DELETE methods', () => { + assert.equal(profileNameFromDeleteRequest({ method: 'GET', path: '/api/profiles/worker' }), null) +}) + +test('profileNameFromDeleteRequest returns null when the path does not match', () => { + assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/sessions' }), null) +}) + +test('profileNameFromDeleteRequest returns null for an empty/whitespace name', () => { + assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/%20' }), null) +}) + +test('profileNameFromDeleteRequest returns null for an undecodable path segment', () => { + assert.equal(profileNameFromDeleteRequest({ method: 'DELETE', path: '/api/profiles/%E0%A4%A' }), null) +}) + +// --------------------------------------------------------------------------- +// decideProfileDeleteAction +// --------------------------------------------------------------------------- + +const deps = { + isDefaultProfile: p => p === 'default', + isValidProfileName: p => /^[a-z0-9][a-z0-9_-]{0,63}$/.test(p), + primaryProfileKey: () => 'primary-profile' +} + +test('decideProfileDeleteAction is a noop for the default profile', () => { + assert.deepEqual(decideProfileDeleteAction('default', deps), { action: 'noop', profile: null }) +}) + +test('decideProfileDeleteAction is a noop for null (no profile parsed)', () => { + assert.deepEqual(decideProfileDeleteAction(null, deps), { action: 'noop', profile: null }) +}) + +test('decideProfileDeleteAction is a noop for an invalid profile name', () => { + assert.deepEqual(decideProfileDeleteAction('Not Valid!', deps), { action: 'noop', profile: null }) +}) + +test('decideProfileDeleteAction tears down the primary backend for the primary profile', () => { + assert.deepEqual(decideProfileDeleteAction('primary-profile', deps), { + action: 'teardown-primary', + profile: 'primary-profile' + }) +}) + +test('decideProfileDeleteAction tears down the pool backend for any other valid profile', () => { + assert.deepEqual(decideProfileDeleteAction('worker', deps), { action: 'teardown-pool', profile: 'worker' }) +}) + +// --------------------------------------------------------------------------- +// resolveRouteProfile +// --------------------------------------------------------------------------- + +test('resolveRouteProfile routes to the primary backend (null) when a profile was torn down', () => { + assert.equal(resolveRouteProfile('worker', 'other-profile'), null) +}) + +test('resolveRouteProfile passes the requested profile through when nothing was torn down', () => { + assert.equal(resolveRouteProfile(null, 'other-profile'), 'other-profile') +}) + +test('resolveRouteProfile passes through undefined when nothing was torn down and no profile was requested', () => { + assert.equal(resolveRouteProfile(null, undefined), undefined) +}) diff --git a/apps/desktop/electron/profile-delete-routing.ts b/apps/desktop/electron/profile-delete-routing.ts new file mode 100644 index 000000000000..d8ba5b48e78c --- /dev/null +++ b/apps/desktop/electron/profile-delete-routing.ts @@ -0,0 +1,95 @@ +// Profile-delete routing logic for the `hermes:api` IPC handler. +// +// When the renderer issues DELETE /api/profiles/<name>, the handler must +// tear down that profile's backend (primary window backend or pool backend) +// and then route the *next* request away from the just-deleted profile's +// pool backend -- spawning a fresh one would call ensure_hermes_home() and +// recreate the profile directory the delete just removed, leaving a zombie +// process behind (issue #52279). +// +// These helpers are pure so they can be unit-tested without Electron. + +/** + * Parse a `hermes:api` request into the profile name a DELETE targets, or + * null when the request is not a profile-delete at all (wrong method, wrong + * path, empty/invalid name). + */ +export function profileNameFromDeleteRequest(request) { + if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') { + return null + } + + const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/) + + if (!match) { + return null + } + + let raw = '' + + try { + raw = decodeURIComponent(match[1]) + } catch { + return null + } + + const name = raw.trim() + + if (!name) { + return null + } + + if (name.toLowerCase() === 'default') { + return 'default' + } + + return name.toLowerCase() +} + +export type ProfileDeleteAction = 'noop' | 'teardown-primary' | 'teardown-pool' + +export interface ProfileDeleteDecision { + action: ProfileDeleteAction + profile: string | null +} + +export interface ProfileDeleteDecisionDeps { + isDefaultProfile: (profile: string) => boolean + isValidProfileName: (profile: string) => boolean + primaryProfileKey: () => string +} + +/** + * Pure decision logic for prepareProfileDeleteRequest: given the parsed + * profile name (or null), decide which side-effecting branch the caller + * should take and what profile name it should ultimately report as + * torn-down. No I/O, no async -- the caller performs the actual teardown + * based on `action`. + */ +export function decideProfileDeleteAction( + profile: string | null, + deps: ProfileDeleteDecisionDeps +): ProfileDeleteDecision { + if (!profile || deps.isDefaultProfile(profile) || !deps.isValidProfileName(profile)) { + return { action: 'noop', profile: null } + } + + if (profile === deps.primaryProfileKey()) { + return { action: 'teardown-primary', profile } + } + + return { action: 'teardown-pool', profile } +} + +/** + * Route the next `hermes:api` request away from the primary/window backend + * whenever a profile was just torn down -- otherwise ensureBackend would + * spawn a fresh pool backend for the deleted profile, whose + * ensure_hermes_home() recreates the directory the delete just removed. + */ +export function resolveRouteProfile( + tornDownProfile: string | null, + profile: string | undefined +): string | null | undefined { + return tornDownProfile ? null : profile +} diff --git a/apps/desktop/electron/session-windows.test.cjs b/apps/desktop/electron/session-windows.test.ts similarity index 96% rename from apps/desktop/electron/session-windows.test.cjs rename to apps/desktop/electron/session-windows.test.ts index 78f19b859e41..fcfca8680730 100644 --- a/apps/desktop/electron/session-windows.test.cjs +++ b/apps/desktop/electron/session-windows.test.ts @@ -1,11 +1,8 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' -const { - buildSessionWindowUrl, - chatWindowWebPreferences, - createSessionWindowRegistry -} = require('./session-windows.cjs') +import { test } from 'vitest' + +import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows' // A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a // test fire the 'closed' event, mirroring the slice of the Electron API the @@ -96,6 +93,7 @@ test('registry opens one window per session and focuses on re-open', () => { const registry = createSessionWindowRegistry() let built = 0 const win = makeFakeWindow() + const factory = () => { built += 1 @@ -145,6 +143,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', () let built = 0 const second = makeFakeWindow() + const result = registry.openOrFocus('s1', () => { built += 1 @@ -158,6 +157,7 @@ test('registry rebuilds a fresh window after the previous one was destroyed', () test('registry ignores empty / non-string session ids', () => { const registry = createSessionWindowRegistry() let built = 0 + const factory = () => { built += 1 diff --git a/apps/desktop/electron/session-windows.cjs b/apps/desktop/electron/session-windows.ts similarity index 91% rename from apps/desktop/electron/session-windows.cjs rename to apps/desktop/electron/session-windows.ts index 5e2f3d4c680c..af55608b0f4e 100644 --- a/apps/desktop/electron/session-windows.cjs +++ b/apps/desktop/electron/session-windows.ts @@ -1,9 +1,9 @@ // Secondary "session windows" — one extra OS window per chat so a user can // work with multiple chats side by side. The pure, Electron-free pieces live // here so they can be unit-tested with node --test (mirroring how the rest of -// electron/*.cjs splits testable logic out of the main.cjs monolith). +// electron/*.ts splits testable logic out of the main.ts monolith). -const { pathToFileURL } = require('node:url') +import { pathToFileURL } from 'node:url' // Secondary windows open at the minimum usable size — a compact side panel for // subagent watch / cmd-click session pop-out, not a second full desktop. @@ -12,7 +12,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // Shared webPreferences for every window that renders the chat transcript — the // primary window AND the secondary session windows. Keeping it in one place is -// the whole point: the two BrowserWindow definitions in main.cjs used to be +// the whole point: the two BrowserWindow definitions in main.ts used to be // hand-copied, and the secondary windows silently lost `backgroundThrottling: // false`, so a streamed answer stalled until the window regained focus. // @@ -21,7 +21,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // blurred/occluded windows. A streaming chat app must keep painting in the // background, so every chat window opts out. The preload path is injected // because it depends on the Electron entry's __dirname. -function chatWindowWebPreferences(preloadPath) { +function chatWindowWebPreferences(preloadPath: string) { return { preload: preloadPath, contextIsolation: true, @@ -42,7 +42,7 @@ function chatWindowWebPreferences(preloadPath) { // scratch window; `watch=1` marks a spectator window (e.g. a running subagent's // session): the renderer resumes it lazily so the gateway never builds an agent // just to stream into it. -function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) { +function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) { const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}` const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}` @@ -115,7 +115,7 @@ function createSessionWindowRegistry() { } } -module.exports = { +export { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, diff --git a/apps/desktop/electron/titlebar-overlay-width.test.cjs b/apps/desktop/electron/titlebar-overlay-width.test.ts similarity index 92% rename from apps/desktop/electron/titlebar-overlay-width.test.cjs rename to apps/desktop/electron/titlebar-overlay-width.test.ts index ccec1015b4f8..0e424fd5c3e8 100644 --- a/apps/desktop/electron/titlebar-overlay-width.test.cjs +++ b/apps/desktop/electron/titlebar-overlay-width.test.ts @@ -1,12 +1,13 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' -const { +import { test } from 'vitest' + +import { MACOS_TAHOE_DARWIN_MAJOR, - OVERLAY_FALLBACK_WIDTH, macTitleBarOverlayHeight, - nativeOverlayWidth -} = require('./titlebar-overlay-width.cjs') + nativeOverlayWidth, + OVERLAY_FALLBACK_WIDTH +} from './titlebar-overlay-width' // This static reservation is only the pre-layout FALLBACK. Once laid out the // renderer reads the exact width from navigator.windowControlsOverlay diff --git a/apps/desktop/electron/titlebar-overlay-width.cjs b/apps/desktop/electron/titlebar-overlay-width.ts similarity index 80% rename from apps/desktop/electron/titlebar-overlay-width.cjs rename to apps/desktop/electron/titlebar-overlay-width.ts index 9336ae89fcef..d6a4c5d1f241 100644 --- a/apps/desktop/electron/titlebar-overlay-width.cjs +++ b/apps/desktop/electron/titlebar-overlay-width.ts @@ -1,6 +1,4 @@ -'use strict' - -const OVERLAY_FALLBACK_WIDTH = 144 +export const OVERLAY_FALLBACK_WIDTH = 144 /** * Static pre-layout reservation (px) for the right-side native window-controls @@ -16,15 +14,18 @@ const OVERLAY_FALLBACK_WIDTH = 144 * * @param {{ isMac?: boolean }} opts */ -function nativeOverlayWidth({ isMac = false } = {}) { - if (isMac) return 0 +export function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) { + if (isMac) { + return 0 + } + return OVERLAY_FALLBACK_WIDTH } // macOS Tahoe ships as Darwin 25 (Sequoia is 24); the Darwin number is truthful, // unlike the product version which macOS reports as 16 or 26 depending on the // build SDK. -const MACOS_TAHOE_DARWIN_MAJOR = 25 +export const MACOS_TAHOE_DARWIN_MAJOR = 25 /** * Height (px) to pass to `titleBarOverlay` on macOS. Tahoe (Darwin 25+) @@ -36,8 +37,6 @@ const MACOS_TAHOE_DARWIN_MAJOR = 25 * * @param {{ darwinMajor?: number, titlebarHeight?: number }} opts */ -function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) { +export function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) { return darwinMajor >= MACOS_TAHOE_DARWIN_MAJOR ? 0 : titlebarHeight } - -module.exports = { MACOS_TAHOE_DARWIN_MAJOR, OVERLAY_FALLBACK_WIDTH, macTitleBarOverlayHeight, nativeOverlayWidth } diff --git a/apps/desktop/electron/update-count.test.cjs b/apps/desktop/electron/update-count.test.ts similarity index 94% rename from apps/desktop/electron/update-count.test.cjs rename to apps/desktop/electron/update-count.test.ts index fdac4fd744ad..b7e1e89a6583 100644 --- a/apps/desktop/electron/update-count.test.cjs +++ b/apps/desktop/electron/update-count.test.ts @@ -1,7 +1,8 @@ -'use strict' -const test = require('node:test') -const assert = require('node:assert/strict') -const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { resolveBehindCount, shouldCountCommits } from './update-count' // FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0` // unconditionally, so a shallow checkout with no merge-base surfaced the bogus diff --git a/apps/desktop/electron/update-count.cjs b/apps/desktop/electron/update-count.ts similarity index 90% rename from apps/desktop/electron/update-count.cjs rename to apps/desktop/electron/update-count.ts index de8d57c4ee6b..23fb6cac1342 100644 --- a/apps/desktop/electron/update-count.cjs +++ b/apps/desktop/electron/update-count.ts @@ -1,5 +1,3 @@ -'use strict' - // Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful // number worth computing. On a SHALLOW checkout (installer clones with // --depth 1) the local history often shares no merge-base with the freshly @@ -19,10 +17,14 @@ function shouldCountCommits({ isShallow, hasMergeBase }) { // (developers / Docker dev images) keep the exact count path unchanged. function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) { if (!shouldCountCommits({ isShallow, hasMergeBase })) { - if (currentSha && targetSha && currentSha === targetSha) return 0 + if (currentSha && targetSha && currentSha === targetSha) { + return 0 + } + return 1 // behind by an unknown amount — show a generic "update available" } + return Number.parseInt(countStr, 10) || 0 } -module.exports = { resolveBehindCount, shouldCountCommits } +export { resolveBehindCount, shouldCountCommits } diff --git a/apps/desktop/electron/update-marker.test.cjs b/apps/desktop/electron/update-marker.test.ts similarity index 87% rename from apps/desktop/electron/update-marker.test.cjs rename to apps/desktop/electron/update-marker.test.ts index d84483714c60..3da49396cc8d 100644 --- a/apps/desktop/electron/update-marker.test.cjs +++ b/apps/desktop/electron/update-marker.test.ts @@ -1,9 +1,9 @@ /** - * Tests for electron/update-marker.cjs — the in-app update mutual-exclusion + * Tests for electron/update-marker.ts — the in-app update mutual-exclusion * marker that prevents a desktop relaunched mid-update from spawning a backend * the updater then kills in a loop (#50238). * - * Run with: node --test electron/update-marker.test.cjs + * Run with: node --test electron/update-marker.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * Why this matters: the gate must (a) report a live update only when the @@ -12,16 +12,24 @@ * strand future launches, and (c) self-heal by deleting a stale marker file. */ -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('fs') -const os = require('os') -const path = require('path') +import fs from 'fs' +import assert from 'node:assert/strict' +import os from 'os' +import path from 'path' -const { markerPath, isPidAlive, readLiveUpdateMarker, writeUpdateMarker, UPDATE_MARKER_MAX_AGE_MS } = require('./update-marker.cjs') +import { test } from 'vitest' + +import { + isPidAlive, + markerPath, + readLiveUpdateMarker, + UPDATE_MARKER_MAX_AGE_MS, + writeUpdateMarker +} from './update-marker' function tmpHome(tag) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-marker-${tag}-`)) + return dir } @@ -29,10 +37,12 @@ function writeMarker(home, pid, startedAtSec) { fs.writeFileSync(markerPath(home), `${pid}\n${startedAtSec}`) } -const ALIVE = () => true // injected kill that "succeeds" => pid alive -const DEAD = () => { +const ALIVE: typeof process.kill = () => true // injected kill that "succeeds" => pid alive + +const DEAD: typeof process.kill = () => { const err = new Error('no such process') - err.code = 'ESRCH' + + ;(err as any).code = 'ESRCH' throw err } @@ -85,9 +95,11 @@ test('isPidAlive: own pid is alive, impossible pid is dead', () => { test('isPidAlive: EPERM counts as alive (process owned by another user)', () => { const eperm = () => { const err = new Error('operation not permitted') - err.code = 'EPERM' + + ;(err as any).code = 'EPERM' throw err } + assert.equal(isPidAlive(4242, eperm), true) }) diff --git a/apps/desktop/electron/update-marker.cjs b/apps/desktop/electron/update-marker.ts similarity index 87% rename from apps/desktop/electron/update-marker.cjs rename to apps/desktop/electron/update-marker.ts index da3df6a8d4ae..543fce0451d0 100644 --- a/apps/desktop/electron/update-marker.cjs +++ b/apps/desktop/electron/update-marker.ts @@ -16,20 +16,20 @@ * * This module holds the PURE, side-effect-light logic (path, pid liveness, * parse + staleness) so it is unit-testable without booting Electron. The - * polling/boot-progress wrapper lives in main.cjs where the boot-progress and + * polling/boot-progress wrapper lives in main.ts where the boot-progress and * log sinks are. */ -const fs = require('fs') -const path = require('path') +import fs from 'fs' +import path from 'path' // Even with a live-looking PID, never treat a marker older than this as a live // update. A full update (git pull + pip + desktop rebuild) is minutes, not tens // of minutes; past this the marker is almost certainly stale (e.g. the OS // recycled the pid onto an unrelated process), so the gate self-heals. -const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000 +export const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000 -function markerPath(hermesHome) { +export function markerPath(hermesHome) { return path.join(hermesHome, '.hermes-update-in-progress') } @@ -37,10 +37,14 @@ function markerPath(hermesHome) { // not deliver a signal — it just probes existence/permission. ESRCH => dead; // EPERM => alive but owned by another user (still "alive" for our purposes). // Injectable `kill` keeps it unit-testable. -function isPidAlive(pid, kill = process.kill.bind(process)) { - if (!Number.isInteger(pid) || pid <= 0) return false +export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(process)) { + if (!Number.isInteger(pid) || pid <= 0) { + return false + } + try { kill(pid, 0) + return true } catch (err) { return Boolean(err && err.code === 'EPERM') @@ -59,9 +63,21 @@ function isPidAlive(pid, kill = process.kill.bind(process)) { * Pure-ish: file I/O against the given path, plus an injectable pid probe and * clock for tests. */ -function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS } = {}) { +export function readLiveUpdateMarker( + hermesHome, + { + kill, + now = Date.now, + maxAgeMs = UPDATE_MARKER_MAX_AGE_MS + }: { + now?: () => number + maxAgeMs?: number + kill?: typeof process.kill + } = {} +) { const file = markerPath(hermesHome) let raw + try { raw = fs.readFileSync(file, 'utf8') } catch { @@ -80,8 +96,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD } catch { void 0 } + return null } + return { pid, ageMs } } @@ -107,9 +125,10 @@ function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPD * If the updater never starts (spawn failure) the marker still contains a * real PID, so `readLiveUpdateMarker` will self-heal once that PID exits. */ -function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { +export function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { const file = markerPath(hermesHome) const startedAt = Math.floor(now() / 1000) + try { fs.writeFileSync(file, `${pid}\n${startedAt}\n`, 'utf8') } catch { @@ -117,11 +136,3 @@ function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { // updater will write its own when it reaches run_update. } } - -module.exports = { - UPDATE_MARKER_MAX_AGE_MS, - markerPath, - isPidAlive, - readLiveUpdateMarker, - writeUpdateMarker -} diff --git a/apps/desktop/electron/update-rebuild.test.cjs b/apps/desktop/electron/update-rebuild.test.ts similarity index 84% rename from apps/desktop/electron/update-rebuild.test.cjs rename to apps/desktop/electron/update-rebuild.test.ts index 623effa4d138..6c2d75245500 100644 --- a/apps/desktop/electron/update-rebuild.test.cjs +++ b/apps/desktop/electron/update-rebuild.test.ts @@ -1,8 +1,8 @@ /** - * Tests for electron/update-rebuild.cjs — the retry-once policy for the desktop + * Tests for electron/update-rebuild.ts — the retry-once policy for the desktop * `--build-only` rebuild during self-update. * - * Run with: node --test electron/update-rebuild.test.cjs + * Run with: node --test electron/update-rebuild.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * Why this matters: a first rebuild can return nonzero on a still-settling tree @@ -12,10 +12,11 @@ * success, and must run at most twice. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' -const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs') +import { test } from 'vitest' + +import { runRebuildWithRetry, shouldRetryRebuild } from './update-rebuild' test('shouldRetryRebuild retries only on a non-success exit', () => { assert.equal(shouldRetryRebuild(0), false) @@ -25,30 +26,39 @@ test('shouldRetryRebuild retries only on a non-success exit', () => { test('a clean first rebuild runs once and does not retry', async () => { const codes = [] + const result = await runRebuildWithRetry(attempt => { codes.push(attempt) + return Promise.resolve({ code: 0 }) }) + assert.deepEqual(codes, [0]) assert.equal(result.code, 0) }) test('a failed first rebuild retries once and succeeds', async () => { const codes = [] + const result = await runRebuildWithRetry(attempt => { codes.push(attempt) + return Promise.resolve({ code: attempt === 0 ? 1 : 0 }) }) + assert.deepEqual(codes, [0, 1]) assert.equal(result.code, 0) }) test('a rebuild that keeps failing runs at most twice and reports the failure', async () => { const codes = [] + const result = await runRebuildWithRetry(attempt => { codes.push(attempt) + return Promise.resolve({ code: 1, error: 'rebuild-failed' }) }) + assert.deepEqual(codes, [0, 1]) assert.equal(result.code, 1) assert.equal(result.error, 'rebuild-failed') diff --git a/apps/desktop/electron/update-rebuild.cjs b/apps/desktop/electron/update-rebuild.ts similarity index 92% rename from apps/desktop/electron/update-rebuild.cjs rename to apps/desktop/electron/update-rebuild.ts index ec8a948316dc..a2a3581eccd8 100644 --- a/apps/desktop/electron/update-rebuild.cjs +++ b/apps/desktop/electron/update-rebuild.ts @@ -1,5 +1,3 @@ -'use strict' - /** * Retry-once policy for the desktop `--build-only` rebuild during self-update. * @@ -20,10 +18,12 @@ function shouldRetryRebuild(code) { */ async function runRebuildWithRetry(rebuild) { let result = await rebuild(0) + if (shouldRetryRebuild(result.code)) { result = await rebuild(1) } + return result } -module.exports = { shouldRetryRebuild, runRebuildWithRetry } +export { runRebuildWithRetry, shouldRetryRebuild } diff --git a/apps/desktop/electron/update-relaunch.test.cjs b/apps/desktop/electron/update-relaunch.test.ts similarity index 94% rename from apps/desktop/electron/update-relaunch.test.cjs rename to apps/desktop/electron/update-relaunch.test.ts index de0a76efeecf..54e42eabf9b2 100644 --- a/apps/desktop/electron/update-relaunch.test.cjs +++ b/apps/desktop/electron/update-relaunch.test.ts @@ -1,8 +1,8 @@ /** - * Tests for electron/update-relaunch.cjs — the pure decision + script helpers + * Tests for electron/update-relaunch.ts — the pure decision + script helpers * behind the Linux in-app update relaunch (#45205). * - * Run with: node --test electron/update-relaunch.test.cjs + * Run with: node --test electron/update-relaunch.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * What this locks (review acceptance criteria for PR #45205): @@ -17,24 +17,25 @@ * (keep a working window) unless a non-interactive fallback applies. */ -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { execFileSync } = require('node:child_process') +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' -const { - unpackedDirName, - resolveUnpackedRelease, - decideRelaunchOutcome, - sandboxPreflight, - sandboxFallbackFromEnv, +import { test } from 'vitest' + +import { + buildRelaunchScript, collectRelaunchArgs, collectRelaunchEnv, - buildRelaunchScript, - shellQuote -} = require('./update-relaunch.cjs') + decideRelaunchOutcome, + resolveUnpackedRelease, + sandboxFallbackFromEnv, + sandboxPreflight, + shellQuote, + unpackedDirName +} from './update-relaunch' const ROOT = '/home/u/.hermes/hermes-agent' const UNPACKED = path.join(ROOT, 'apps', 'desktop', 'release', 'linux-unpacked') @@ -91,6 +92,7 @@ test('decideRelaunchOutcome: only under-unpacked + sandbox-ok relaunches', () => // --------------------------------------------------------------------------- const fakeStat = (uid, mode) => () => ({ uid, mode }) + const throwStat = () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } @@ -150,6 +152,7 @@ test('collectRelaunchArgs drops Electron internals, keeps user/launcher args', ( '--profile=work', // app flag — keep '--remote-debugging-port=9222' // internal — drop ] + assert.deepEqual(collectRelaunchArgs(argv), ['--no-sandbox', 'hermes://open/agent/42', '--profile=work']) assert.deepEqual(collectRelaunchArgs(undefined), []) }) @@ -160,16 +163,19 @@ test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt- HERMES_DESKTOP_REMOTE_URL: 'http://box:9119', HERMES_DESKTOP_REMOTE_TOKEN: 'secret', HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes', + HERMES_DESKTOP_APP_NAME: 'HermesSandbox', ELECTRON_DISABLE_SANDBOX: '1', // sandbox opt-out — preserved PATH: '/usr/bin', // not preserved HOME: '/home/u', // not preserved UNRELATED: 'x' } + assert.deepEqual(collectRelaunchEnv(env), { HERMES_HOME: '/home/u/.hermes', HERMES_DESKTOP_REMOTE_URL: 'http://box:9119', HERMES_DESKTOP_REMOTE_TOKEN: 'secret', HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes', + HERMES_DESKTOP_APP_NAME: 'HermesSandbox', ELECTRON_DISABLE_SANDBOX: '1' }) assert.deepEqual(collectRelaunchEnv(null), {}) @@ -207,6 +213,7 @@ test('buildRelaunchScript embeds pid/exec/args/env/cwd and is valid bash', () => // It must be syntactically valid bash (`bash -n`). Write to a temp file and lint. const tmp = path.join(os.tmpdir(), `hermes-relaunch-test-${Date.now()}.sh`) fs.writeFileSync(tmp, script) + try { execFileSync('bash', ['-n', tmp], { stdio: 'pipe' }) } finally { @@ -222,13 +229,16 @@ test('buildRelaunchScript with no args/env still lints clean', () => { env: {}, cwd: '' }) + const tmp = path.join(os.tmpdir(), `hermes-relaunch-test2-${Date.now()}.sh`) fs.writeFileSync(tmp, script) + try { execFileSync('bash', ['-n', tmp], { stdio: 'pipe' }) } finally { fs.rmSync(tmp, { force: true }) } + // exec line has no trailing args. assert.match(script, /exec '\/opt\/Hermes\/Hermes'\n/) }) diff --git a/apps/desktop/electron/update-relaunch.cjs b/apps/desktop/electron/update-relaunch.ts similarity index 89% rename from apps/desktop/electron/update-relaunch.cjs rename to apps/desktop/electron/update-relaunch.ts index 62032cde8c98..46ea789bbf69 100644 --- a/apps/desktop/electron/update-relaunch.cjs +++ b/apps/desktop/electron/update-relaunch.ts @@ -1,12 +1,10 @@ -'use strict' - /** - * update-relaunch.cjs — pure decision + script-generation helpers for the + * update-relaunch.ts — pure decision + script-generation helpers for the * Linux in-app update relaunch (#45205). * - * Extracted from main.cjs's `applyUpdatesPosixInApp` so the security- and + * Extracted from main.ts's `applyUpdatesPosixInApp` so the security- and * correctness-critical "do we relaunch, or land on a manual terminal state?" - * decision is unit-testable without booting Electron (main.cjs + * decision is unit-testable without booting Electron (main.ts * `require('electron')` at load). * * Background @@ -37,12 +35,18 @@ * the closeable manual-restart terminal state instead. */ -const path = require('node:path') +import path from 'node:path' // Map process.platform → electron-builder's `release/<dir>-unpacked` name. function unpackedDirName(platform) { - if (platform === 'darwin') return 'mac-unpacked' // not used (mac swaps bundles) - if (platform === 'win32') return 'win-unpacked' + if (platform === 'darwin') { + return 'mac-unpacked' + } // not used (mac swaps bundles) + + if (platform === 'win32') { + return 'win-unpacked' + } + return 'linux-unpacked' } @@ -56,15 +60,20 @@ function unpackedDirName(platform) { * `.../release/linux-unpacked-evil` can't masquerade as `.../release/linux-unpacked`. */ function resolveUnpackedRelease(execPath, updateRoot, platform) { - if (!execPath || !updateRoot) return null + if (!execPath || !updateRoot) { + return null + } + const releaseDir = path.join(updateRoot, 'apps', 'desktop', 'release') const unpacked = path.join(releaseDir, unpackedDirName(platform)) const normalizedExec = path.resolve(String(execPath)) // execPath must be the unpacked dir itself or a descendant of it. const withSep = unpacked.endsWith(path.sep) ? unpacked : unpacked + path.sep + if (normalizedExec === unpacked || normalizedExec.startsWith(withSep)) { return unpacked } + return null } @@ -81,8 +90,14 @@ function resolveUnpackedRelease(execPath, updateRoot, platform) { * app. Closeable manual-restart terminal state. */ function decideRelaunchOutcome({ underUnpacked, sandboxOk }) { - if (!underUnpacked) return 'guiSkew' - if (!sandboxOk) return 'manual' + if (!underUnpacked) { + return 'guiSkew' + } + + if (!sandboxOk) { + return 'manual' + } + return 'relaunch' } @@ -99,9 +114,13 @@ function decideRelaunchOutcome({ underUnpacked, sandboxOk }) { * `statSync` is injectable so this is testable without a real setuid file. */ function sandboxPreflight(unpackedDir, statSync) { - if (!unpackedDir) return { ok: false, reason: 'no-unpacked-dir', path: null } + if (!unpackedDir) { + return { ok: false, reason: 'no-unpacked-dir', path: null } + } + const sandboxPath = path.join(unpackedDir, 'chrome-sandbox') let st + try { st = statSync(sandboxPath) } catch { @@ -109,15 +128,22 @@ function sandboxPreflight(unpackedDir, statSync) { // sandbox; nothing to block the relaunch. return { ok: true, reason: 'no-sandbox-helper', path: sandboxPath } } + const ownedByRoot = st.uid === 0 const hasSetuid = (st.mode & 0o4000) !== 0 + if (ownedByRoot && hasSetuid) { return { ok: true, reason: 'launchable', path: sandboxPath } } + if (!ownedByRoot && !hasSetuid) { return { ok: false, reason: 'not-root-not-setuid', path: sandboxPath } } - if (!ownedByRoot) return { ok: false, reason: 'not-root', path: sandboxPath } + + if (!ownedByRoot) { + return { ok: false, reason: 'not-root', path: sandboxPath } + } + return { ok: false, reason: 'not-setuid', path: sandboxPath } } @@ -126,7 +152,7 @@ function sandboxPreflight(unpackedDir, statSync) { * environment. The reviewer asked us to integrate with any existing * `--no-sandbox` / chrome-sandbox handling. A repo grep found NO existing * non-interactive sandbox fallback in the desktop app (the only chrome-sandbox - * reference is documentation in scripts/before-pack.cjs). The one signal that + * reference is documentation in scripts/before-pack.ts). The one signal that * DOES exist is the standard Electron escape hatch: ELECTRON_DISABLE_SANDBOX=1 * (and the equivalent `--no-sandbox` already present in the launch args). If * the user has set that, the rebuilt binary will start even with a broken @@ -137,8 +163,15 @@ function sandboxPreflight(unpackedDir, statSync) { */ function sandboxFallbackFromEnv(env, launchArgs) { const disable = String((env && env.ELECTRON_DISABLE_SANDBOX) || '').trim() - if (disable === '1' || disable.toLowerCase() === 'true') return true - if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) return true + + if (disable === '1' || disable.toLowerCase() === 'true') { + return true + } + + if (Array.isArray(launchArgs) && launchArgs.some(a => a === '--no-sandbox')) { + return true + } + return false } @@ -176,9 +209,15 @@ const INTERNAL_ARG_PREFIXES = [ * the exec path itself; there is no entry-script arg as in a dev run). */ function collectRelaunchArgs(argv) { - if (!Array.isArray(argv)) return [] + if (!Array.isArray(argv)) { + return [] + } + return argv.filter(arg => { - if (typeof arg !== 'string' || arg.length === 0) return false + if (typeof arg !== 'string' || arg.length === 0) { + return false + } + return !INTERNAL_ARG_PREFIXES.some(prefix => prefix.endsWith('=') ? arg.startsWith(prefix) : arg === prefix || arg.startsWith(prefix + '=') ) @@ -197,13 +236,21 @@ const PRESERVED_ENV_PREFIXES = ['HERMES_DESKTOP_'] function collectRelaunchEnv(env) { const out = {} - if (!env || typeof env !== 'object') return out + + if (!env || typeof env !== 'object') { + return out + } + for (const [key, value] of Object.entries(env)) { - if (value == null) continue + if (value == null) { + continue + } + if (PRESERVED_ENV_KEYS.includes(key) || PRESERVED_ENV_PREFIXES.some(p => key.startsWith(p))) { out[key] = String(value) } } + return out } @@ -223,8 +270,10 @@ function buildRelaunchScript({ pid, execPath, args, env, cwd }) { const exports = Object.entries(env || {}) .map(([k, v]) => `export ${k}=${shellQuote(v)}`) .join('\n') + const quotedArgs = (args || []).map(shellQuote).join(' ') const cwdLine = cwd ? `cd ${shellQuote(cwd)} 2>/dev/null || true` : '' + // NOTE: `exec` replaces the watcher process with the relaunched app, so the // re-exec inherits exactly the env/cwd we set above. return `#!/bin/bash @@ -249,17 +298,17 @@ exec ${shellQuote(execPath)}${quotedArgs ? ' ' + quotedArgs : ''} ` } -module.exports = { - unpackedDirName, - resolveUnpackedRelease, - decideRelaunchOutcome, - sandboxPreflight, - sandboxFallbackFromEnv, +export { + buildRelaunchScript, collectRelaunchArgs, collectRelaunchEnv, - buildRelaunchScript, - shellQuote, + decideRelaunchOutcome, INTERNAL_ARG_PREFIXES, PRESERVED_ENV_KEYS, - PRESERVED_ENV_PREFIXES + PRESERVED_ENV_PREFIXES, + resolveUnpackedRelease, + sandboxFallbackFromEnv, + sandboxPreflight, + shellQuote, + unpackedDirName } diff --git a/apps/desktop/electron/update-remote.test.cjs b/apps/desktop/electron/update-remote.test.ts similarity index 92% rename from apps/desktop/electron/update-remote.test.cjs rename to apps/desktop/electron/update-remote.test.ts index 0dfba970138b..80dc8b1dd0b5 100644 --- a/apps/desktop/electron/update-remote.test.cjs +++ b/apps/desktop/electron/update-remote.test.ts @@ -1,8 +1,8 @@ /** - * Tests for electron/update-remote.cjs — the remote-detection helpers that + * Tests for electron/update-remote.ts — the remote-detection helpers that * keep passive update checks off the SSH origin for official installs. * - * Run with: node --test electron/update-remote.test.cjs + * Run with: node --test electron/update-remote.test.ts * (Wired into npm test:desktop:platforms in package.json.) * * Why this matters: a public install can carry @@ -15,16 +15,17 @@ * never prompts and should keep the normal fetch path). */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' -const { - OFFICIAL_REPO_HTTPS_URL, - OFFICIAL_REPO_CANONICAL, +import { test } from 'vitest' + +import { canonicalGitHubRemote, + isOfficialSshRemote, isSshRemote, - isOfficialSshRemote -} = require('./update-remote.cjs') + OFFICIAL_REPO_CANONICAL, + OFFICIAL_REPO_HTTPS_URL +} from './update-remote' test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => { assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) diff --git a/apps/desktop/electron/update-remote.cjs b/apps/desktop/electron/update-remote.ts similarity index 76% rename from apps/desktop/electron/update-remote.cjs rename to apps/desktop/electron/update-remote.ts index 1e99bbe88771..1c5a6d57c74c 100644 --- a/apps/desktop/electron/update-remote.cjs +++ b/apps/desktop/electron/update-remote.ts @@ -8,8 +8,8 @@ * which needs no auth and cannot prompt. Active update/apply flows are left * unchanged. * - * Extracted from main.cjs so the security-critical remote detection is unit - * testable without booting Electron (main.cjs requires('electron') at load). + * Extracted from main.ts so the security-critical remote detection is unit + * testable without booting Electron (main.ts requires('electron') at load). */ const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git' @@ -19,8 +19,12 @@ const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent' // no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo // compare equal. function canonicalGitHubRemote(url) { - if (!url) return '' + if (!url) { + return '' + } + let value = String(url).trim() + if (value.startsWith('git@github.com:')) { value = `github.com/${value.slice('git@github.com:'.length)}` } else if (value.startsWith('ssh://git@github.com/')) { @@ -28,13 +32,21 @@ function canonicalGitHubRemote(url) { } else { try { const parsed = new URL(value) - if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}` + + if (parsed.hostname && parsed.pathname) { + value = `${parsed.hostname}${parsed.pathname}` + } } catch { // Leave non-URL forms unchanged. } } + value = value.trim().replace(/\/+$/, '') - if (value.endsWith('.git')) value = value.slice(0, -4) + + if (value.endsWith('.git')) { + value = value.slice(0, -4) + } + return value.toLowerCase() } @@ -42,6 +54,7 @@ function isSshRemote(url) { const value = String(url || '') .trim() .toLowerCase() + return value.startsWith('git@') || value.startsWith('ssh://') } @@ -49,10 +62,4 @@ function isOfficialSshRemote(url) { return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL } -module.exports = { - OFFICIAL_REPO_HTTPS_URL, - OFFICIAL_REPO_CANONICAL, - canonicalGitHubRemote, - isSshRemote, - isOfficialSshRemote -} +export { canonicalGitHubRemote, isOfficialSshRemote, isSshRemote, OFFICIAL_REPO_CANONICAL, OFFICIAL_REPO_HTTPS_URL } diff --git a/apps/desktop/electron/vscode-marketplace.test.cjs b/apps/desktop/electron/vscode-marketplace.test.ts similarity index 95% rename from apps/desktop/electron/vscode-marketplace.test.cjs rename to apps/desktop/electron/vscode-marketplace.test.ts index 45169044bfa3..adbdfbe6f027 100644 --- a/apps/desktop/electron/vscode-marketplace.test.cjs +++ b/apps/desktop/electron/vscode-marketplace.test.ts @@ -1,9 +1,8 @@ -'use strict' +import assert from 'node:assert' -const assert = require('node:assert') -const test = require('node:test') +import { test } from 'vitest' -const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs') +import { __testing, extractThemes, readCentralDirectory } from './vscode-marketplace' // Build a minimal zip with stored (uncompressed) entries so the test controls // the bytes exactly — exercises the central-directory reader + theme extraction @@ -72,6 +71,7 @@ test('extractThemes reads contributed color themes (resolving ./ paths)', () => themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }] } }) + const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } }) const zip = makeZip([ diff --git a/apps/desktop/electron/vscode-marketplace.cjs b/apps/desktop/electron/vscode-marketplace.ts similarity index 97% rename from apps/desktop/electron/vscode-marketplace.cjs rename to apps/desktop/electron/vscode-marketplace.ts index 55e49bc30ecd..3fe737dc9cb0 100644 --- a/apps/desktop/electron/vscode-marketplace.cjs +++ b/apps/desktop/electron/vscode-marketplace.ts @@ -1,5 +1,3 @@ -'use strict' - /** * VS Code Marketplace color-theme fetcher (main process). * @@ -14,8 +12,8 @@ * zip library into the desktop bundle for a feature this small. */ -const https = require('node:https') -const zlib = require('node:zlib') +import https from 'node:https' +import zlib from 'node:zlib' const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery' const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage' @@ -30,7 +28,7 @@ function request( url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS -) { +): Promise<Buffer<ArrayBuffer>> { return new Promise((resolve, reject) => { const req = https.request(url, { method, headers }, res => { const status = res.statusCode ?? 0 @@ -102,6 +100,7 @@ async function resolveExtension(id) { // IncludeCategoryAndTags | IncludeLatestVersionOnly = 914. flags: 914 }) + const extension = json?.results?.[0]?.extensions?.[0] if (!extension) { @@ -127,6 +126,7 @@ async function resolveExtension(id) { /** POST an ExtensionQuery payload and return the parsed gallery response. */ async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) { const body = JSON.stringify(payload) + const raw = await request(GALLERY_QUERY_URL, { method: 'POST', headers: { @@ -332,10 +332,6 @@ async function fetchMarketplaceThemes(id) { return { extensionId: trimmed, displayName, themes } } -module.exports = { - fetchMarketplaceThemes, - searchMarketplaceThemes, - extractThemes, - readCentralDirectory, - __testing: { themeEntryName, looksLikeIconTheme } -} +const __testing = { themeEntryName, looksLikeIconTheme } + +export { __testing, extractThemes, fetchMarketplaceThemes, readCentralDirectory, searchMarketplaceThemes } diff --git a/apps/desktop/electron/window-state.test.cjs b/apps/desktop/electron/window-state.test.ts similarity index 91% rename from apps/desktop/electron/window-state.test.cjs rename to apps/desktop/electron/window-state.test.ts index a0f68ce333cd..40c8fe1798e2 100644 --- a/apps/desktop/electron/window-state.test.cjs +++ b/apps/desktop/electron/window-state.test.ts @@ -4,19 +4,20 @@ * clamping, and the debounce that collapses mid-drag write storms. */ -const test = require('node:test') -const assert = require('node:assert/strict') +import assert from 'node:assert/strict' -const { - DEFAULT_WIDTH, - DEFAULT_HEIGHT, - MIN_WIDTH, - MIN_HEIGHT, - sanitizeWindowState, - onScreen, +import { test, vi } from 'vitest' + +import { computeWindowOptions, - debounce -} = require('./window-state.cjs') + debounce, + DEFAULT_HEIGHT, + DEFAULT_WIDTH, + MIN_HEIGHT, + MIN_WIDTH, + onScreen, + sanitizeWindowState +} from './window-state' // A single 1920×1080 monitor (work area trimmed for the taskbar). const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }] @@ -118,9 +119,10 @@ test('computeWindowOptions does not clamp when displays are unknown', () => { // ─── debounce ────────────────────────────────────────────────────────────── -test('debounce coalesces a burst into one trailing run', t => { - t.mock.timers.enable({ apis: ['setTimeout'] }) +test('debounce coalesces a burst into one trailing run', () => { + vi.useFakeTimers() let calls = 0 + const d = debounce(() => { calls += 1 }, 250) @@ -129,15 +131,18 @@ test('debounce coalesces a burst into one trailing run', t => { d() d() assert.equal(calls, 0) - t.mock.timers.tick(249) + vi.advanceTimersByTime(249) assert.equal(calls, 0) - t.mock.timers.tick(1) + vi.advanceTimersByTime(1) assert.equal(calls, 1) + + vi.useRealTimers() }) -test('debounce.flush runs now and cancels the pending timer', t => { - t.mock.timers.enable({ apis: ['setTimeout'] }) +test('debounce.flush runs now and cancels the pending timer', () => { + vi.useFakeTimers() let calls = 0 + const d = debounce(() => { calls += 1 }, 250) @@ -145,6 +150,8 @@ test('debounce.flush runs now and cancels the pending timer', t => { d() d.flush() assert.equal(calls, 1) - t.mock.timers.tick(1000) + vi.advanceTimersByTime(1000) assert.equal(calls, 1) + + vi.useRealTimers() }) diff --git a/apps/desktop/electron/window-state.cjs b/apps/desktop/electron/window-state.ts similarity index 84% rename from apps/desktop/electron/window-state.cjs rename to apps/desktop/electron/window-state.ts index 6157e469b24e..56510e882736 100644 --- a/apps/desktop/electron/window-state.cjs +++ b/apps/desktop/electron/window-state.ts @@ -2,7 +2,7 @@ * Pure geometry helpers for window-state.json — restoring the main window's * size, position, and maximized flag across launches. Side-effect-free so the * part that actually matters (rejecting garbage + off-screen bounds) is - * unit-testable without booting Electron; main.cjs owns the file I/O and the + * unit-testable without booting Electron; main.ts owns the file I/O and the * live `screen` displays. */ @@ -21,41 +21,67 @@ const MIN_VISIBLE = 48 const finite = v => typeof v === 'number' && Number.isFinite(v) const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi)) +interface SanitizedWindowState { + width: number + height: number + isMaximized: boolean + x?: number + y?: number +} + // Parse raw JSON → clean state, or null if garbage. width/height are required // and floored; x/y survive only as a finite pair; isMaximized is strict. -function sanitizeWindowState(raw) { - if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null +function sanitizeWindowState(raw?: any): SanitizedWindowState | null { + if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) { + return null + } - const state = { + const state: SanitizedWindowState = { width: Math.max(MIN_WIDTH, Math.round(raw.width)), height: Math.max(MIN_HEIGHT, Math.round(raw.height)), isMaximized: raw.isMaximized === true } + if (finite(raw.x) && finite(raw.y)) { state.x = Math.round(raw.x) state.y = Math.round(raw.y) } + return state } // True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both // axes. `displays` is Electron's screen.getAllDisplays() shape. function onScreen(bounds, displays) { - if (!Array.isArray(displays)) return false + if (!Array.isArray(displays)) { + return false + } + return displays.some(({ workArea: a } = {}) => { - if (!a) return false + if (!a) { + return false + } + const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x) const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y) + return x >= MIN_VISIBLE && y >= MIN_VISIBLE }) } +interface WindowOptions { + width: number + height: number + x?: number + y?: number +} + // Sanitized state (or null) → BrowserWindow size/position options. Always sets // width/height, capped to the largest current display so a size saved on a // since-disconnected bigger monitor can't exceed any screen the user now has. // Sets x/y only when still on-screen; otherwise Electron centers the window. -function computeWindowOptions(state, displays) { - const opts = { +function computeWindowOptions(state, displays): WindowOptions { + const opts: WindowOptions = { width: finite(state?.width) ? state.width : DEFAULT_WIDTH, height: finite(state?.height) ? state.height : DEFAULT_HEIGHT } @@ -67,6 +93,7 @@ function computeWindowOptions(state, displays) { : m, { width: 0, height: 0 } ) + if (cap.width && cap.height) { opts.width = clamp(opts.width, MIN_WIDTH, cap.width) opts.height = clamp(opts.height, MIN_HEIGHT, cap.height) @@ -81,6 +108,7 @@ function computeWindowOptions(state, displays) { opts.x = state.x opts.y = state.y } + return opts } @@ -89,6 +117,7 @@ function computeWindowOptions(state, displays) { // cancels the pending timer — used on close, before the window is gone. function debounce(fn, delayMs) { let timer = null + const debounced = () => { clearTimeout(timer) timer = setTimeout(() => { @@ -96,22 +125,24 @@ function debounce(fn, delayMs) { fn() }, delayMs) } + debounced.flush = () => { clearTimeout(timer) timer = null fn() } + return debounced } -module.exports = { - DEFAULT_WIDTH, +export { + computeWindowOptions, + debounce, DEFAULT_HEIGHT, - MIN_WIDTH, + DEFAULT_WIDTH, MIN_HEIGHT, MIN_VISIBLE, - sanitizeWindowState, + MIN_WIDTH, onScreen, - computeWindowOptions, - debounce + sanitizeWindowState } diff --git a/apps/desktop/electron/windows-child-options.test.ts b/apps/desktop/electron/windows-child-options.test.ts new file mode 100644 index 000000000000..33fcac357783 --- /dev/null +++ b/apps/desktop/electron/windows-child-options.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { stopBackendChild } from './backend-child' +import { hiddenWindowsChildOptions } from './windows-child-options' + +test('hiddenWindowsChildOptions adds windowsHide:true on Windows when unset', () => { + assert.deepEqual(hiddenWindowsChildOptions({}, true), { windowsHide: true }) +}) + +test('hiddenWindowsChildOptions preserves an existing windowsHide:false on Windows', () => { + assert.deepEqual(hiddenWindowsChildOptions({ windowsHide: false }, true), { windowsHide: false }) +}) + +test('hiddenWindowsChildOptions preserves an existing windowsHide:true on Windows', () => { + assert.deepEqual(hiddenWindowsChildOptions({ windowsHide: true }, true), { windowsHide: true }) +}) + +test('hiddenWindowsChildOptions leaves options unchanged off Windows', () => { + assert.deepEqual(hiddenWindowsChildOptions({}, false), {}) + assert.deepEqual(hiddenWindowsChildOptions({ stdio: 'ignore' }, false), { stdio: 'ignore' }) +}) + +test('hiddenWindowsChildOptions merges windowsHide alongside other options on Windows', () => { + assert.deepEqual(hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, true), { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true + }) +}) + +test('hiddenWindowsChildOptions defaults isWindows from process.platform when omitted', () => { + const result = hiddenWindowsChildOptions({}) + const expectedHide = process.platform === 'win32' + + assert.equal(Boolean(result.windowsHide), expectedHide) +}) + +function makeChild(overrides: Partial<{ pid: number | null; killed: boolean }> = {}) { + const calls: string[] = [] + + return { + calls, + child: { + kill: (signal: string) => { + calls.push(signal) + }, + killed: overrides.killed ?? false, + pid: 'pid' in overrides ? overrides.pid : 1234 + } + } +} + +test('stopBackendChild tree-kills on Windows when the child has a pid', () => { + const { child, calls } = makeChild({ pid: 4242 }) + const treeKillCalls: number[] = [] + + stopBackendChild(child, { + forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), + isWindows: true + }) + + assert.deepEqual(treeKillCalls, [4242]) + assert.deepEqual(calls, [], 'SIGTERM must not be sent when the Windows tree-kill path is taken') +}) + +test('stopBackendChild sends SIGTERM on non-Windows platforms', () => { + const { child, calls } = makeChild({ pid: 4242 }) + const treeKillCalls: number[] = [] + + stopBackendChild(child, { + forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), + isWindows: false + }) + + assert.deepEqual(calls, ['SIGTERM']) + assert.deepEqual(treeKillCalls, [], 'tree-kill must not run off Windows') +}) + +test('stopBackendChild falls back to SIGTERM on Windows when the pid is not an integer', () => { + const { child, calls } = makeChild({ pid: null }) + const treeKillCalls: number[] = [] + + stopBackendChild(child, { + forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), + isWindows: true + }) + + assert.deepEqual(calls, ['SIGTERM']) + assert.deepEqual(treeKillCalls, []) +}) + +test('stopBackendChild is a no-op for an already-killed child', () => { + const { child, calls } = makeChild({ killed: true }) + const treeKillCalls: number[] = [] + + stopBackendChild(child, { + forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), + isWindows: true + }) + + assert.deepEqual(calls, []) + assert.deepEqual(treeKillCalls, []) +}) + +test('stopBackendChild is a no-op for a null/undefined child', () => { + const treeKillCalls: number[] = [] + + assert.doesNotThrow(() => { + stopBackendChild(null, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true }) + stopBackendChild(undefined, { forceKillProcessTree: (pid: number) => treeKillCalls.push(pid), isWindows: true }) + }) + assert.deepEqual(treeKillCalls, []) +}) + +test('stopBackendChild swallows errors thrown by the kill strategy', () => { + const child = { + kill: () => { + throw new Error('ESRCH: no such process') + }, + killed: false, + pid: 99 + } + + assert.doesNotThrow(() => { + stopBackendChild(child, { + forceKillProcessTree: () => {}, + isWindows: false + }) + }) +}) diff --git a/apps/desktop/electron/windows-child-options.ts b/apps/desktop/electron/windows-child-options.ts new file mode 100644 index 000000000000..547136bad460 --- /dev/null +++ b/apps/desktop/electron/windows-child-options.ts @@ -0,0 +1,37 @@ +/** + * windows-child-options.ts + * + * Shared helper for opting Windows child processes (spawn/execFileSync) into + * a hidden console. Windows spawns a visible console window per child by + * default; every desktop-launched helper process (git, curl, taskkill, the + * backend itself, the bootstrap PowerShell runner, ...) needs `windowsHide: + * true` so the user doesn't see consoles flashing on screen. + * + * Extracted into its own dependency-free module (no electron import) so it + * can be unit-tested directly for both platforms without reading source + * text, and so main.ts and bootstrap-runner.ts share exactly one + * implementation instead of each defining their own copy. + */ + +import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process' + +/** + * Merge `windowsHide: true` into `options` when running on Windows, unless + * the caller already specified a `windowsHide` value (which is preserved + * as-is, including an explicit `false` for cases that intentionally want a + * visible/interactive console). No-op on non-Windows platforms. + * + * @param options - spawn/execFileSync options to (possibly) augment. + * @param isWindows - defaults to the real platform check; injectable for + * tests so both branches can be exercised without mocking process.platform. + */ +export function hiddenWindowsChildOptions( + options: any = {}, + isWindows: boolean = process.platform === 'win32' +): ExecFileSyncOptionsWithStringEncoding { + if (!isWindows || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { + return options as any + } + + return { ...options, windowsHide: true } as any +} diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs deleted file mode 100644 index c15dc3b7b505..000000000000 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ /dev/null @@ -1,116 +0,0 @@ -'use strict' - -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') - -const ELECTRON_DIR = __dirname - -function readElectronFile(name) { - return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') -} - -function requireHiddenChildOptions(source, needle) { - const match = needle instanceof RegExp ? needle.exec(source) : null - const index = needle instanceof RegExp ? (match?.index ?? -1) : source.indexOf(needle) - assert.notEqual(index, -1, `missing call site: ${needle}`) - const snippet = source.slice(index, index + 700) - assert.match( - snippet, - /hiddenWindowsChildOptions\(/, - `expected ${needle} to wrap child-process options with hiddenWindowsChildOptions` - ) -} - -test('desktop background child processes opt into hidden Windows consoles', () => { - const source = readElectronFile('main.cjs') - - assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) - - requireHiddenChildOptions(source, "execFileSync(\n 'reg'") - requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/) - requireHiddenChildOptions(source, /spawn\(\s*resolveGitBinary\(\)/) - requireHiddenChildOptions(source, "execFileSync('taskkill'") - requireHiddenChildOptions(source, /spawn\(\s*command,\s*args/) - requireHiddenChildOptions(source, "spawn('curl'") - requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/) - requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/) - requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/) - - assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, backendArgs\)/) - assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/) - assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/) - assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.backendArgs\]/) -}) - -test('desktop backend launches console python so child consoles are inherited, not pythonw', () => { - const source = readElectronFile('main.cjs') - - // The flash fix is structural: the backend runs as a console-subsystem - // python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it - // owns ONE windowless console that every descendant spawn inherits. Launching - // it as GUI-subsystem pythonw.exe is what made each child allocate (and flash) - // its own console, so the backend command must never be pythonw. - assert.doesNotMatch(source, /pythonw\.exe'\)/, 'backend must not be launched via pythonw.exe') - assert.doesNotMatch( - source, - /function getNoConsoleVenvPython\b/, - 'pythonw-conversion helper should be gone; console python is launched directly' - ) - assert.doesNotMatch( - source, - /function applyWindowsNoConsoleSpawnHints\b/, - 'pythonw spawn-hint rewriter should be gone' - ) - - // Console python restores stdout, so the port is announced on the normal - // HERMES_DASHBOARD_READY stdout line — no ready-file side channel is set. - assert.doesNotMatch(source, /readyFile: true/, 'no backend should opt into the pythonw ready-file path') - - // Both desktop backend launches must still go through hiddenWindowsChildOptions - // so the single backend console is created windowless. - requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/) - requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/) -}) - -test('desktop backend teardown tree-kills Windows backend descendants', () => { - const source = readElectronFile('main.cjs') - - const helperIndex = source.indexOf('function stopBackendChild(child)') - assert.notEqual(helperIndex, -1, 'missing backend teardown helper') - const helperSnippet = source.slice(helperIndex, helperIndex + 500) - assert.match(helperSnippet, /IS_WINDOWS && Number\.isInteger\(child\.pid\)/) - assert.match(helperSnippet, /forceKillProcessTree\(child\.pid\)/) - assert.match(helperSnippet, /child\.kill\('SIGTERM'\)/) - - const resetIndex = source.indexOf('function resetHermesConnection()') - assert.notEqual(resetIndex, -1, 'missing resetHermesConnection') - const resetSnippet = source.slice(resetIndex, resetIndex + 300) - assert.match(resetSnippet, /stopBackendChild\(hermesProcess\)/) - assert.doesNotMatch(resetSnippet, /hermesProcess\.kill\('SIGTERM'\)/) - - const quitIndex = source.indexOf("app.on('before-quit'") - assert.notEqual(quitIndex, -1, 'missing before-quit handler') - const quitSnippet = source.slice(quitIndex, quitIndex + 900) - assert.match(quitSnippet, /stopBackendChild\(hermesProcess\)/) - assert.doesNotMatch(quitSnippet, /hermesProcess\.kill\('SIGTERM'\)/) -}) - -test('intentional or interactive desktop child processes stay documented', () => { - const source = readElectronFile('main.cjs') - - assert.match(source, /windowsHide: false/) - assert.match(source, /handOffWindowsBootstrapRecovery/) - assert.match(source, /'--repair', '--branch'/) - assert.match(source, /'--update', '--branch'/) - assert.match(source, /nodePty\.spawn\(command, args/) - assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/) -}) - -test('bootstrap PowerShell runner hides Windows console children', () => { - const source = readElectronFile('bootstrap-runner.cjs') - - assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) - requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/) -}) diff --git a/apps/desktop/electron/windows-hermes-path.test.ts b/apps/desktop/electron/windows-hermes-path.test.ts new file mode 100644 index 000000000000..8600c3501858 --- /dev/null +++ b/apps/desktop/electron/windows-hermes-path.test.ts @@ -0,0 +1,209 @@ +// Unit tests for the pure Windows `hermes` resolution helpers extracted from +// main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and +// unwrapWindowsVenvHermesCommand(). These pin the two Windows resolution bugs +// that caused desktop reinstall loops: +// 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the +// empty extension, or an extensionless Git-Bash `hermes` shim shadows +// the real hermes.cmd/hermes.exe. +// 2. chooseUpdaterArgs() — must gate on haveRealInstall (any real-install +// signal), not just the hermes.exe console-script shim, or healthy +// installs get forced into a destructive --repair. +// 3. resolveVenvHermesCommand() — must probe the venv python via +// canImportHermesCli() before trusting it, or a broken venv gets +// re-selected forever instead of falling through to bootstrap. + +import assert from 'node:assert/strict' +import path from 'node:path' + +import { test } from 'vitest' + +import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path' + +test('buildPathExtCandidates: Windows tries PATHEXT extensions before the empty extension', () => { + const extensions = buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', true) + + assert.deepEqual(extensions, ['.COM', '.EXE', '.BAT', '.CMD', '']) + assert.equal(extensions[extensions.length - 1], '', 'empty extension must be last, not first') + assert.notEqual(extensions[0], '', 'the buggy empty-extension-first order must not return') +}) + +test('buildPathExtCandidates: defaults to .COM;.EXE;.BAT;.CMD when PATHEXT is unset on Windows', () => { + assert.deepEqual(buildPathExtCandidates(undefined, true), ['.COM', '.EXE', '.BAT', '.CMD', '']) +}) + +test('buildPathExtCandidates: respects a custom PATHEXT, still empty-last', () => { + assert.deepEqual(buildPathExtCandidates('.EXE;.PS1', true), ['.EXE', '.PS1', '']) +}) + +test('buildPathExtCandidates: non-Windows only tries the bare name', () => { + assert.deepEqual(buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', false), ['']) + assert.deepEqual(buildPathExtCandidates(undefined, false), ['']) +}) + +test('chooseUpdaterArgs: gentle --update when a real-install signal is present', () => { + assert.deepEqual(chooseUpdaterArgs(true, 'main'), ['--update', '--branch', 'main']) +}) + +test('chooseUpdaterArgs: destructive --repair only when NO real-install signal is present', () => { + assert.deepEqual(chooseUpdaterArgs(false, 'main'), ['--repair', '--branch', 'main']) +}) + +test('chooseUpdaterArgs: passes the branch through unchanged in both cases', () => { + assert.deepEqual(chooseUpdaterArgs(true, 'release/1.2'), ['--update', '--branch', 'release/1.2']) + assert.deepEqual(chooseUpdaterArgs(false, 'release/1.2'), ['--repair', '--branch', 'release/1.2']) +}) + +function makeDeps(overrides: Partial<Parameters<typeof resolveVenvHermesCommand>[2]> = {}) { + return { + isWindows: true, + isCommandScript: () => false, + fileExists: () => true, + directoryExists: () => false, + canImportHermesCli: () => true, + getVenvPython: (venvRoot: string) => `${venvRoot}/Scripts/python.exe`, + getVenvSitePackagesEntries: () => [], + buildDesktopBackendEnv: () => ({ FAKE_ENV: '1' }), + hermesHome: '/fake/hermes-home', + resolvePath: (...segments: string[]) => segments.join('/').replace(/\/+/g, '/'), + dirname: (p: string) => p.slice(0, p.lastIndexOf('/')) || '/', + basename: (p: string) => p.slice(p.lastIndexOf('/') + 1), + rememberLog: () => {}, + ...overrides + } +} + +test('resolveVenvHermesCommand: returns null off Windows', () => { + const deps = makeDeps({ isWindows: false }) + + assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null) +}) + +test('resolveVenvHermesCommand: returns null for a .cmd/.bat script command', () => { + const deps = makeDeps({ isCommandScript: () => true }) + + assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.cmd', [], deps), null) +}) + +test('resolveVenvHermesCommand: returns null when the basename is not hermes/hermes.exe', () => { + const deps = makeDeps() + + assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/python.exe', [], deps), null) +}) + +test('resolveVenvHermesCommand: returns null when the parent dir is not Scripts', () => { + const deps = makeDeps() + + assert.equal(resolveVenvHermesCommand('/root/venv/bin/hermes.exe', [], deps), null) +}) + +test('resolveVenvHermesCommand: returns null when the venv python does not exist on disk', () => { + const deps = makeDeps({ fileExists: () => false }) + + assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null) +}) + +test('resolveVenvHermesCommand: probes the venv python before trusting it (returns null on failed probe)', () => { + let probed = false + + const deps = makeDeps({ + canImportHermesCli: (python: string) => { + probed = true + assert.equal(python, '/root/venv/Scripts/python.exe') + + return false + } + }) + + const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve'], deps) + + assert.equal(probed, true, 'must probe the venv interpreter; a broken venv must not be re-selected forever') + assert.equal(result, null, 'a failed probe must fall through (return null) so the resolver reaches bootstrap') +}) + +test('resolveVenvHermesCommand: returns the resolved python backend descriptor when the probe passes', () => { + const deps = makeDeps() + const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve', '--port', '0'], deps) + + assert.ok(result, 'a passing probe must return a backend descriptor, not null') + assert.equal(result.command, '/root/venv/Scripts/python.exe') + assert.deepEqual(result.args, ['-m', 'hermes_cli.main', 'serve', '--port', '0']) + assert.equal(result.bootstrap, false) + assert.equal(result.kind, 'python') + assert.equal(result.shell, false) + assert.deepEqual(result.env, { FAKE_ENV: '1' }) +}) + +test('resolveVenvHermesCommand: is case-insensitive on hermes.exe and the Scripts dir name', () => { + const deps = makeDeps() + + assert.ok(resolveVenvHermesCommand('/root/venv/Scripts/HERMES.EXE', [], deps)) + assert.ok(resolveVenvHermesCommand('/root/venv/SCRIPTS/hermes.exe', [], deps)) +}) + +// ── getVenvSitePackagesEntries ───────────────────────────────────────────── + +test('getVenvSitePackagesEntries: returns Lib/site-packages on Windows when it exists', () => { + const expected = path.join('C:\\venv', 'Lib', 'site-packages') + + const result = getVenvSitePackagesEntries('C:\\venv', { + isWindows: true, + directoryExists: p => p === expected + }) + + assert.deepEqual(result, [expected]) +}) + +test('getVenvSitePackagesEntries: returns empty on Windows when site-packages does not exist', () => { + const result = getVenvSitePackagesEntries('C:\\venv', { + isWindows: true, + directoryExists: () => false + }) + + assert.deepEqual(result, []) +}) + +test('getVenvSitePackagesEntries: reads pyvenv.cfg version on POSIX and resolves lib/pythonX.Y/site-packages', () => { + const result = getVenvSitePackagesEntries('/venv', { + isWindows: false, + directoryExists: p => p === '/venv/lib/python3.12/site-packages', + readFile: () => 'version_info = 3.12.1\n' + }) + + assert.deepEqual(result, ['/venv/lib/python3.12/site-packages']) +}) + +test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg is missing', () => { + const result = getVenvSitePackagesEntries('/venv', { + isWindows: false, + directoryExists: () => true, + readFile: () => undefined + }) + + assert.deepEqual(result, []) +}) + +test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg has no version_info', () => { + const result = getVenvSitePackagesEntries('/venv', { + isWindows: false, + directoryExists: () => true, + readFile: () => 'home = /usr/bin\n' + }) + + assert.deepEqual(result, []) +}) + +test('getVenvSitePackagesEntries: returns empty on POSIX when version is present but site-packages dir is absent', () => { + const result = getVenvSitePackagesEntries('/venv', { + isWindows: false, + directoryExists: () => false, + readFile: () => 'version_info = 3.11\n' + }) + + assert.deepEqual(result, []) +}) + +test('getVenvSitePackagesEntries: returns empty for a falsy venvRoot', () => { + assert.deepEqual(getVenvSitePackagesEntries('', { isWindows: true, directoryExists: () => true }), []) + assert.deepEqual(getVenvSitePackagesEntries(null, { isWindows: true, directoryExists: () => true }), []) + assert.deepEqual(getVenvSitePackagesEntries(undefined, { isWindows: true, directoryExists: () => true }), []) +}) diff --git a/apps/desktop/electron/windows-hermes-path.ts b/apps/desktop/electron/windows-hermes-path.ts new file mode 100644 index 000000000000..a40524910ecb --- /dev/null +++ b/apps/desktop/electron/windows-hermes-path.ts @@ -0,0 +1,287 @@ +/** + * windows-hermes-path.ts + * + * Pure, dependency-injected pieces of Windows `hermes` resolution pulled out + * of main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and + * unwrapWindowsVenvHermesCommand(). Each of the three functions here pins one + * of the Windows resolution bugs that caused desktop reinstall loops: + * + * 1. buildPathExtCandidates() — findOnPath() tried the empty extension + * FIRST, so an extensionless Git-Bash `hermes` shim shadowed the real + * hermes.cmd/hermes.exe; the shim then failed the --version probe and + * the desktop fell through to a spurious bootstrap/repair. The fix: + * PATHEXT extensions first, empty extension LAST. + * 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() chose + * --update vs the destructive --repair by checking ONLY + * venv\Scripts\hermes.exe (the console-script shim, written at the END + * of venv setup and absent in interrupted states), so it escalated to a + * full venv recreate even on healthy installs. The fix: gate on ANY + * real-install signal, not just the shim. + * 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned + * the venv python with NO runtime probe (bypassing the caller's + * --version check too), so a venv broken mid-update (e.g. missing + * python-dotenv) was re-selected forever: Retry / "Repair install" + * resolved the same dead interpreter instead of falling through to the + * bootstrap installer. The fix: probe-before-trust. + * + * Kept in a standalone ts module (no Electron imports, dependencies passed + * as parameters) so it can be unit-tested with `node --test` without + * mocking Electron or the filesystem, same pattern as backend-probes.ts and + * backend-command.ts. + */ + +import fs from 'node:fs' +import path from 'node:path' + +/** + * Build the ordered list of extensions findOnPath() should try when + * resolving a bare command name off PATH. + * + * On Windows this MUST try PATHEXT extensions (.COM;.EXE;.BAT;.CMD by + * default) BEFORE the bare/empty-extension name: a real command resolves via + * its .exe/.cmd per Windows command-resolution semantics, and an + * extensionless file (e.g. a Git-Bash shell-script shim named `hermes`) must + * not shadow `hermes.cmd`/`hermes.exe`. The empty entry is kept LAST so + * callers that already include the extension (py.exe, pwsh.exe, + * powershell.exe) still resolve. + * + * On non-Windows platforms there is no PATHEXT concept: only the bare name + * is tried. + * + * @param {string | undefined} pathext - process.env.PATHEXT (or undefined). + * @param {boolean} isWindows + * @returns {string[]} extensions to try, in order, always ending in ''. + */ +export function buildPathExtCandidates(pathext: string | undefined, isWindows: boolean): string[] { + if (!isWindows) { + return [''] + } + + return [...(pathext || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), ''] +} + +/** + * Choose the Windows bootstrap-recovery updater invocation: the gentle + * in-place --update when ANY real-install signal is present, the + * destructive --repair (full venv recreate) otherwise. + * + * haveRealInstall must be computed by the caller from ALL real-install + * signals (venv python interpreter, venv hermes shim, bootstrap-complete + * marker) — gating on just the hermes.exe console-script shim alone is the + * regression this function's callers must avoid: that shim is written at + * the END of venv setup and is absent in exactly the interrupted/quarantined + * states this recovery exists to heal. + * + * @param {boolean} haveRealInstall + * @param {string} branch + * @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main']. + */ +export function chooseUpdaterArgs(haveRealInstall: boolean, branch: string): string[] { + return haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] +} + +/** + * Resolve the site-packages directory entries for a Python venv. + * + * On Windows, venv layout is `<venvRoot>/Lib/site-packages`. + * On POSIX, it's `<venvRoot>/lib/python<version>/site-packages` where + * `<version>` (e.g. `3.12`) is read from the venv's `pyvenv.cfg` + * `version_info` field. + * + * Returns only directories that actually exist on disk. Returns an empty + * array when `venvRoot` is falsy or no matching site-packages dir is found. + * + * Extracted from main.ts so the platform branching can be tested without + * reading source text. `isWindows` and `directoryExists` are injectable; + * `readFile` defaults to `fs.readFileSync` but can be overridden for tests. + */ +export function getVenvSitePackagesEntries( + venvRoot: string | undefined | null, + opts: { + isWindows?: boolean + directoryExists?: (p: string) => boolean + readFile?: (p: string) => string | undefined + } = {} +): string[] { + const entries: string[] = [] + + if (!venvRoot) { + return entries + } + + const isWindows = opts.isWindows ?? process.platform === 'win32' + + const directoryExists = opts.directoryExists ?? ((p: string) => { + try { + return fs.statSync(p).isDirectory() + } catch { + return false + } + }) + + const readFile = opts.readFile ?? ((p: string) => { + try { + return fs.readFileSync(p, 'utf8') + } catch { + return undefined + } + }) + + if (isWindows) { + const sitePackages = path.join(venvRoot, 'Lib', 'site-packages') + + if (directoryExists(sitePackages)) { + entries.push(sitePackages) + } + + return entries + } + + const cfg = readFile(path.join(venvRoot, 'pyvenv.cfg')) + + const version = (() => { + if (!cfg) { + return null + } + + const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im) + + return match ? match[1].trim() : null + })() + + if (version) { + const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages') + + if (directoryExists(sitePackages)) { + entries.push(sitePackages) + } + } + + return entries +} + +export interface ResolveVenvHermesCommandDeps { + isWindows: boolean + isCommandScript: (command: string) => boolean + fileExists: (filePath: string) => boolean + directoryExists: (filePath: string) => boolean + canImportHermesCli: (python: string, opts?: { env?: Record<string, string> }) => boolean + getVenvPython: (venvRoot: string) => string + getVenvSitePackagesEntries: (venvRoot: string) => string[] + buildDesktopBackendEnv: (opts: { + hermesHome: string + pythonPathEntries: string[] + venvRoot: string + }) => Record<string, string> + hermesHome: string + resolvePath: (...segments: string[]) => string + dirname: (p: string) => string + basename: (p: string) => string + rememberLog?: (message: string) => void +} + +/** + * If `command` is a Windows venv `hermes`/`hermes.exe` console-script shim + * (i.e. `<venvRoot>/Scripts/hermes(.exe)`), resolve it to the underlying + * venv python invoked as `python -m hermes_cli.main <backendArgs>` — but + * ONLY after smoke-testing that interpreter with canImportHermesCli(). A + * venv whose update died mid-`pip install` still has python.exe + hermes.exe + * on disk, but the backend dies on its first import (e.g. + * ModuleNotFoundError: dotenv) before the gateway ever binds. Returning it + * unprobed also bypasses the caller's `--version` probe, so Retry/"Repair + * install" re-resolves the same broken venv forever instead of falling + * through to the bootstrap installer. + * + * Mirrors isActiveRuntimeUsable(): probes with the checkout on PYTHONPATH so + * a healthy source-tree venv passes. + * + * Returns null when `command` is not a venv hermes shim, the underlying + * python doesn't exist, or the import probe fails. Otherwise returns the + * resolved backend descriptor. + */ +export function resolveVenvHermesCommand( + command: string, + backendArgs: string[], + deps: ResolveVenvHermesCommandDeps +): { + label: string + command: string + args: string[] + bootstrap: false + env: Record<string, string> + kind: 'python' + root: string + shell: false +} | null { + const { + isWindows, + isCommandScript, + fileExists, + directoryExists, + canImportHermesCli, + getVenvPython, + getVenvSitePackagesEntries, + buildDesktopBackendEnv, + hermesHome, + resolvePath, + dirname, + basename, + rememberLog + } = deps + + if (!isWindows || !command || isCommandScript(command)) { + return null + } + + const resolved = resolvePath(String(command)) + + if (!/^hermes(?:\.exe)?$/i.test(basename(resolved))) { + return null + } + + const scriptsDir = dirname(resolved) + + if (basename(scriptsDir).toLowerCase() !== 'scripts') { + return null + } + + const venvRoot = dirname(scriptsDir) + const python = getVenvPython(venvRoot) + + if (!fileExists(python)) { + return null + } + + const root = dirname(venvRoot) + + if ( + !canImportHermesCli(python, { + env: { + PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH] + .filter((entry): entry is string => Boolean(entry)) + .join(path.delimiter) + } + }) + ) { + rememberLog?.( + `Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.` + ) + + return null + } + + return { + label: `existing Hermes Python at ${python}`, + command: python, + args: ['-m', 'hermes_cli.main', ...backendArgs], + bootstrap: false, + env: buildDesktopBackendEnv({ + hermesHome, + pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)], + venvRoot + }), + kind: 'python', + root, + shell: false + } +} diff --git a/apps/desktop/electron/windows-hermes-resolution.test.cjs b/apps/desktop/electron/windows-hermes-resolution.test.cjs deleted file mode 100644 index 40e2658a1220..000000000000 --- a/apps/desktop/electron/windows-hermes-resolution.test.cjs +++ /dev/null @@ -1,84 +0,0 @@ -'use strict' - -// Regression guards for Windows `hermes` resolution in main.cjs. -// -// main.cjs has no module.exports, so these follow the repo's source-assertion -// test pattern (see windows-child-process.test.cjs). They pin the two Windows -// resolution bugs that caused desktop reinstall loops: -// 1. findOnPath() tried the empty extension FIRST, so an extensionless -// Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the -// shim then failed the --version probe and the desktop fell through to a -// spurious bootstrap/repair. -// 2. handOffWindowsBootstrapRecovery() chose --update vs the destructive -// --repair by checking ONLY venv\Scripts\hermes.exe (the console-script -// shim, written at the END of venv setup and absent in interrupted -// states), so it escalated to a full venv recreate even on healthy -// installs. -// 3. unwrapWindowsVenvHermesCommand() returned the venv python with NO -// runtime probe (bypassing the caller's --version check too), so a venv -// broken mid-update (e.g. missing python-dotenv) was re-selected forever: -// Retry / "Repair install" resolved the same dead interpreter instead of -// falling through to the bootstrap installer. - -const test = require('node:test') -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') - -function readMain() { - return fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8').replace(/\r\n/g, '\n') -} - -test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => { - const source = readMain() - // Fixed order: PATHEXT first, empty string LAST. - assert.match( - source, - /\(process\.env\.PATHEXT \|\| '\.COM;\.EXE;\.BAT;\.CMD'\)\.split\(';'\)\.filter\(Boolean\), ''\]/, - 'extensions array must end with the empty string, not start with it' - ) - // The buggy empty-first order must not return. - assert.doesNotMatch( - source, - /\['', \.\.\.\(process\.env\.PATHEXT/, - 'empty-extension-first order regressed: an extensionless shim can shadow hermes.cmd/.exe' - ) -}) - -test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => { - const source = readMain() - assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall') - assert.match(source, /fileExists\(venvPython\)/, 'recovery must accept the venv interpreter as a real-install signal') - assert.match( - source, - /\.hermes-bootstrap-complete/, - 'recovery must accept the bootstrap-complete marker as a real-install signal' - ) - assert.match(source, /updaterArgs = haveRealInstall \? \['--update'/, 'updaterArgs must gate on haveRealInstall') - // The old too-narrow check (only venv\Scripts\hermes.exe) must not return. - assert.doesNotMatch( - source, - /updaterArgs = fileExists\(venvHermes\) \?/, - 'recovery regressed to gating only on the hermes.exe shim, which forces destructive --repair' - ) -}) - -test('unwrapWindowsVenvHermesCommand smoke-tests the venv python before trusting it', () => { - const source = readMain() - const fnStart = source.indexOf('function unwrapWindowsVenvHermesCommand(') - assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.cjs') - // Slice out just the function body (up to the next top-level function decl) - const fnEnd = source.indexOf('\nfunction ', fnStart + 1) - const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd) - assert.match( - body, - /canImportHermesCli\(python/, - 'unwrap must probe the venv interpreter; returning it unprobed re-selects a broken venv ' + - 'forever (Retry/Repair loop on a mid-update venv missing e.g. python-dotenv)' - ) - assert.match( - body, - /return null\s*\n\s*\}\s*\n\s*return \{/, - 'a failed probe must fall through (return null) so the resolver reaches the bootstrap rung' - ) -}) diff --git a/apps/desktop/electron/windows-user-env.test.cjs b/apps/desktop/electron/windows-user-env.test.ts similarity index 94% rename from apps/desktop/electron/windows-user-env.test.cjs rename to apps/desktop/electron/windows-user-env.test.ts index 3fee15981901..6b92650b60a5 100644 --- a/apps/desktop/electron/windows-user-env.test.cjs +++ b/apps/desktop/electron/windows-user-env.test.ts @@ -1,7 +1,8 @@ -const assert = require('node:assert/strict') -const { test } = require('node:test') +import assert from 'node:assert/strict' -const { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } = require('./windows-user-env.cjs') +import { test } from 'vitest' + +import { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } from './windows-user-env' // ── parseRegQueryValue ───────────────────────────────────────────────────── @@ -42,25 +43,32 @@ test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => test('readWindowsUserEnvVar returns null off Windows without spawning', () => { let spawned = false + const exec = () => { spawned = true + return '' } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null) assert.equal(spawned, false) }) test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => { const calls = [] + const exec = (cmd, args) => { calls.push([cmd, args]) + return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n' } + const value = readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', env: { DRIVE: 'F:' }, exec }) + assert.equal(value, 'F:\\Hermes') assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]]) }) @@ -69,6 +77,7 @@ test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing) const exec = () => { throw new Error('reg exited 1') } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null) }) diff --git a/apps/desktop/electron/windows-user-env.cjs b/apps/desktop/electron/windows-user-env.ts similarity index 79% rename from apps/desktop/electron/windows-user-env.cjs rename to apps/desktop/electron/windows-user-env.ts index 4bfaba1570df..890cd6f4e103 100644 --- a/apps/desktop/electron/windows-user-env.cjs +++ b/apps/desktop/electron/windows-user-env.ts @@ -1,4 +1,4 @@ -// windows-user-env.cjs +// windows-user-env.ts // // Read a User-scoped environment variable straight from the Windows registry // (HKCU\Environment). @@ -10,7 +10,7 @@ // gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading // the live registry value closes the gap. See #45471. -const { execFileSync } = require('node:child_process') +import { execFileSync } from 'node:child_process' // Parse the output of `reg query HKCU\Environment /v <name>`, which looks like: // @@ -20,15 +20,21 @@ const { execFileSync } = require('node:child_process') // Returns the raw value string (spaces inside the value preserved), or null when // the requested value line isn't present. function parseRegQueryValue(stdout, name) { - if (!stdout || !name) return null + if (!stdout || !name) { + return null + } + const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/ + for (const rawLine of String(stdout).split(/\r?\n/)) { const line = rawLine.trim() const match = line.match(typePattern) + if (match && match[1].toLowerCase() === name.toLowerCase()) { return match[2] } } + return null } @@ -36,9 +42,13 @@ function parseRegQueryValue(stdout, name) { // unexpanded references; plain REG_SZ paths have none, so this is a no-op for // the common F:\... case. Unknown references are left verbatim. function expandWindowsEnvRefs(value, env = process.env) { - if (!value) return value + if (!value) { + return value + } + return value.replace(/%([^%]+)%/g, (whole, name) => { const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase()) + return key != null && env[key] != null ? env[key] : whole }) } @@ -46,9 +56,24 @@ function expandWindowsEnvRefs(value, env = process.env) { // Read a User-scoped env var from HKCU\Environment. Windows-only: returns null // off-Windows (without spawning), on any spawn error, when `reg` exits non-zero // (the value doesn't exist), or when the value is empty. -function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync } = {}) { - if (platform !== 'win32' || !name) return null +function readWindowsUserEnvVar( + name, + { + platform = process.platform, + env = process.env, + exec = execFileSync + }: { + platform?: NodeJS.Platform + env?: NodeJS.ProcessEnv + exec?: typeof execFileSync | ((file?: string, args?: any) => string) + } = {} +) { + if (platform !== 'win32' || !name) { + return null + } + let stdout + try { stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], { encoding: 'utf8', @@ -59,14 +84,16 @@ function readWindowsUserEnvVar(name, { platform = process.platform, env = proces // `reg` missing, or value absent (reg exits 1) — caller falls back. return null } + const raw = parseRegQueryValue(stdout, name) - if (raw == null) return null + + if (raw == null) { + return null + } + const expanded = expandWindowsEnvRefs(raw, env).trim() + return expanded || null } -module.exports = { - expandWindowsEnvRefs, - parseRegQueryValue, - readWindowsUserEnvVar -} +export { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } diff --git a/apps/desktop/electron/workspace-cwd.test.cjs b/apps/desktop/electron/workspace-cwd.test.ts similarity index 77% rename from apps/desktop/electron/workspace-cwd.test.cjs rename to apps/desktop/electron/workspace-cwd.test.ts index 85a044ab3beb..1377b9f7d783 100644 --- a/apps/desktop/electron/workspace-cwd.test.cjs +++ b/apps/desktop/electron/workspace-cwd.test.ts @@ -1,14 +1,15 @@ /** - * Tests for electron/workspace-cwd.cjs. + * Tests for electron/workspace-cwd.ts. * - * Run with: node --test electron/workspace-cwd.test.cjs + * Run with: node --test electron/workspace-cwd.test.ts */ -const test = require('node:test') -const assert = require('node:assert/strict') -const path = require('node:path') +import assert from 'node:assert/strict' +import path from 'node:path' -const { isPackagedInstallPath } = require('./workspace-cwd.cjs') +import { test } from 'vitest' + +import { isPackagedInstallPath } from './workspace-cwd' const installRoot = path.resolve('/opt/Hermes') diff --git a/apps/desktop/electron/workspace-cwd.cjs b/apps/desktop/electron/workspace-cwd.ts similarity index 78% rename from apps/desktop/electron/workspace-cwd.cjs rename to apps/desktop/electron/workspace-cwd.ts index bb5da7771489..79ea87bee439 100644 --- a/apps/desktop/electron/workspace-cwd.cjs +++ b/apps/desktop/electron/workspace-cwd.ts @@ -1,7 +1,7 @@ -const path = require('node:path') +import path from 'node:path' /** True when `dir` lives inside a packaged app bundle / install tree. */ -function isPackagedInstallPath(dir, { installRoots, isPackaged }) { +function isPackagedInstallPath(dir, { installRoots, isPackaged }: { installRoots: string[]; isPackaged: boolean }) { if (!isPackaged || !dir) { return false } @@ -21,7 +21,7 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) { return true } - const rel = path.relative(root, resolved) + const rel = path.relative(root, resolved) as any if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { return true @@ -31,4 +31,4 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) { return false } -module.exports = { isPackagedInstallPath } +export { isPackagedInstallPath } diff --git a/apps/desktop/electron/wsl-clipboard-image.test.cjs b/apps/desktop/electron/wsl-clipboard-image.test.ts similarity index 94% rename from apps/desktop/electron/wsl-clipboard-image.test.cjs rename to apps/desktop/electron/wsl-clipboard-image.test.ts index 343adc1f6d69..311d11ca7dac 100644 --- a/apps/desktop/electron/wsl-clipboard-image.test.cjs +++ b/apps/desktop/electron/wsl-clipboard-image.test.ts @@ -1,12 +1,13 @@ -const assert = require('node:assert/strict') -const test = require('node:test') +import assert from 'node:assert/strict' -const { +import { test } from 'vitest' + +import { decodeClipboardImageBase64, encodePowerShellCommand, powershellCandidates, readWslWindowsClipboardImage -} = require('./wsl-clipboard-image.cjs') +} from './wsl-clipboard-image' const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) @@ -49,10 +50,12 @@ test('decodeClipboardImageBase64 rejects base64 without a PNG signature', () => test('readWslWindowsClipboardImage decodes the first candidate that returns a PNG', () => { const png = fakePngBuffer() const calls = [] - const exec = (cmd, args) => { + + const exec = ((cmd, args) => { calls.push({ cmd, args }) + return png.toString('base64') - } + }) as any const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe'] }) assert.ok(result && result.equals(png)) @@ -65,15 +68,18 @@ test('readWslWindowsClipboardImage decodes the first candidate that returns a PN test('readWslWindowsClipboardImage returns null and stops when stdout is empty (no image)', () => { let count = 0 - const exec = () => { + + const exec = (() => { count += 1 + return '' - } + }) as any const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'] }) + assert.equal(result, null) // Empty stdout means "no image on the clipboard" — don't probe further candidates. assert.equal(count, 1) @@ -82,18 +88,22 @@ test('readWslWindowsClipboardImage returns null and stops when stdout is empty ( test('readWslWindowsClipboardImage falls through to the next candidate when one throws', () => { const png = fakePngBuffer() const seen = [] + const exec = cmd => { seen.push(cmd) + if (cmd === 'powershell.exe') { throw Object.assign(new Error('not found'), { code: 'ENOENT' }) } - return png.toString('base64') + + return png.toString('base64') as any } const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'] }) + assert.ok(result && result.equals(png)) assert.deepEqual(seen, ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']) }) diff --git a/apps/desktop/electron/wsl-clipboard-image.cjs b/apps/desktop/electron/wsl-clipboard-image.ts similarity index 86% rename from apps/desktop/electron/wsl-clipboard-image.cjs rename to apps/desktop/electron/wsl-clipboard-image.ts index c81fe7b2a60b..2859f389c021 100644 --- a/apps/desktop/electron/wsl-clipboard-image.cjs +++ b/apps/desktop/electron/wsl-clipboard-image.ts @@ -1,7 +1,7 @@ // Pull a Windows-host clipboard image from inside WSL2 via PowerShell (WSLg // bridges text but not images). Returns PNG bytes or null; exec injectable. -const { execFileSync } = require('node:child_process') +import { execFileSync } from 'node:child_process' // STA is mandatory: System.Windows.Forms.Clipboard throws ThreadStateException // off a single-threaded apartment. We emit base64 (not raw bytes) so the PNG @@ -33,9 +33,13 @@ function powershellCandidates() { function decodeClipboardImageBase64(stdout) { const b64 = String(stdout || '').trim() - if (!b64) return null + + if (!b64) { + return null + } let buffer + try { buffer = Buffer.from(b64, 'base64') } catch { @@ -44,6 +48,7 @@ function decodeClipboardImageBase64(stdout) { // Guard against partial / garbage output: require a real PNG signature. const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { return null } @@ -54,7 +59,10 @@ function decodeClipboardImageBase64(stdout) { // Read the Windows clipboard image from inside WSL. Returns a PNG Buffer, or // null when there's no image, PowerShell is unreachable, or output is invalid. // Linux-only by contract (caller gates on IS_WSL); never throws. -function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() } = {}) { +function readWslWindowsClipboardImage({ + exec = execFileSync, + candidates = powershellCandidates() +}: { exec?: typeof execFileSync; candidates?: string[] } = {}) { const encoded = encodePowerShellCommand(PS_SCRIPT) for (const ps of candidates) { @@ -72,10 +80,17 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers stdio: ['ignore', 'pipe', 'ignore'] } ) + const decoded = decodeClipboardImageBase64(stdout) - if (decoded) return decoded + + if (decoded) { + return decoded + } + // Empty stdout = no image on the clipboard; stop, don't try fallbacks. - if (String(stdout || '').trim() === '') return null + if (String(stdout || '').trim() === '') { + return null + } } catch { // This powershell.exe candidate is missing/failed — try the next one. } @@ -84,9 +99,4 @@ function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powers return null } -module.exports = { - decodeClipboardImageBase64, - encodePowerShellCommand, - powershellCandidates, - readWslWindowsClipboardImage -} +export { decodeClipboardImageBase64, encodePowerShellCommand, powershellCandidates, readWslWindowsClipboardImage } diff --git a/apps/desktop/electron/wsl-path-bridge.test.ts b/apps/desktop/electron/wsl-path-bridge.test.ts new file mode 100644 index 000000000000..79505a1d394e --- /dev/null +++ b/apps/desktop/electron/wsl-path-bridge.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import { test } from 'vitest' + +import { parseDefaultDistro, resolvePickerDefaultPath, wslPosixToWindowsAccessible } from './wsl-path-bridge' + +test('parseDefaultDistro reads the first distro from clean utf-8 output', () => { + assert.equal(parseDefaultDistro('Ubuntu\nDebian\n'), 'Ubuntu') +}) + +test('parseDefaultDistro survives UTF-16LE NUL bytes older wsl.exe leaves in (WSL#4607)', () => { + // `wsl.exe -l -q` emits UTF-16LE without a BOM on builds that ignore + // WSL_UTF8; decoded as utf8 that reads as NUL-interleaved text. + const utf16ish = '\0U\0b\0u\0n\0t\0u\0\r\0\n\0D\0e\0b\0i\0a\0n\0' + assert.equal(parseDefaultDistro(utf16ish), 'Ubuntu') +}) + +test('parseDefaultDistro strips the default-marker and blank lines', () => { + assert.equal(parseDefaultDistro('\n* Ubuntu\nDebian\n'), 'Ubuntu') + assert.equal(parseDefaultDistro(' \n\n'), null) +}) + +test('wslPosixToWindowsAccessible maps a drvfs mount to its Windows drive', () => { + assert.equal(wslPosixToWindowsAccessible('/mnt/c/Users/alex', 'Ubuntu'), 'C:\\Users\\alex') + assert.equal(wslPosixToWindowsAccessible('/mnt/d', 'Ubuntu'), 'D:\\') +}) + +test('wslPosixToWindowsAccessible maps an in-distro POSIX path to a UNC share', () => { + assert.equal(wslPosixToWindowsAccessible('/home/alex/proj', 'Ubuntu'), '\\\\wsl.localhost\\Ubuntu\\home\\alex\\proj') +}) + +test('wslPosixToWindowsAccessible leaves non-absolute / already-Windows paths alone', () => { + assert.equal(wslPosixToWindowsAccessible('C:\\Users\\alex', 'Ubuntu'), 'C:\\Users\\alex') + assert.equal(wslPosixToWindowsAccessible('relative/dir', 'Ubuntu'), 'relative/dir') +}) + +test('resolvePickerDefaultPath bridges a WSL cwd but passes Windows paths and empties through', () => { + assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu'), '\\\\wsl.localhost\\Ubuntu\\home\\alex') + assert.equal(resolvePickerDefaultPath('C:\\proj', 'Ubuntu'), 'C:\\proj') + assert.equal(resolvePickerDefaultPath(undefined, 'Ubuntu'), undefined) +}) diff --git a/apps/desktop/electron/wsl-path-bridge.ts b/apps/desktop/electron/wsl-path-bridge.ts new file mode 100644 index 000000000000..0b14604df671 --- /dev/null +++ b/apps/desktop/electron/wsl-path-bridge.ts @@ -0,0 +1,136 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' + +// Bridges WSL/POSIX paths into forms the *Windows host* can open, for the case +// where the desktop UI runs on Windows and the gateway runs inside WSL (remote +// mode). Only the Windows-side direction lives here: the native folder dialog's +// defaultPath and the fs read path. The reverse (whatever path the backend +// receives → POSIX) is handled once, gateway-side, in +// hermes_constants.translate_cwd_for_wsl_backend, so it stays picker-agnostic. + +const IS_WINDOWS = process.platform === 'win32' +const WIN_DRIVE_RE = /^([A-Za-z]):[\\/]/ +// `/mnt/c` and `/mnt/c/...` (drvfs default automount root). +const WSL_MOUNT_RE = /^\/mnt\/([a-z])(?:\/(.*))?$/i + +let cachedDistro: null | string = null +let cachedUncBase: null | string = null + +/** + * Pick the default distro from `wsl.exe -l -q` output. + * + * `wsl.exe` emits UTF-16LE without a BOM unless `WSL_UTF8=1` (WSL >= 0.64), so + * older builds leave NUL bytes between characters even when we ask for utf8 — + * strip them defensively before splitting. The default distro is the first + * (`*`-marked, decoration removed by `-q`) entry. See microsoft/WSL#4607. + */ +export function parseDefaultDistro(raw: string): null | string { + return ( + String(raw || '') + .replace(/\0/g, '') + .split(/\r?\n/) + .map(line => line.replace(/^\*?\s*/, '').trim()) + .find(Boolean) || null + ) +} + +/** Default WSL distro name (cached). Falls back to `Ubuntu`. */ +export function resolveDefaultWslDistro(): string { + if (cachedDistro) { + return cachedDistro + } + + if (!IS_WINDOWS) { + cachedDistro = 'Ubuntu' + + return cachedDistro + } + + try { + const out = execFileSync('wsl.exe', ['-l', '-q'], { + encoding: 'utf8', + env: { ...process.env, WSL_UTF8: '1' }, + timeout: 2000, + windowsHide: true + }) + cachedDistro = parseDefaultDistro(out) || 'Ubuntu' + } catch { + cachedDistro = 'Ubuntu' + } + + return cachedDistro +} + +// `\\wsl.localhost\<distro>` (Win11 / Win10 >= 21364) with a `\\wsl$\<distro>` +// fallback for older builds. Probed once; defaults to wsl.localhost. +function wslUncBase(distro: string): string { + if (cachedUncBase) { + return cachedUncBase + } + + const modern = `\\\\wsl.localhost\\${distro}` + const legacy = `\\\\wsl$\\${distro}` + + try { + if (!fs.existsSync(modern) && fs.existsSync(legacy)) { + cachedUncBase = legacy + + return cachedUncBase + } + } catch { + // Network-path probe failed — prefer the modern form. + } + + cachedUncBase = modern + + return cachedUncBase +} + +/** + * A WSL/POSIX path → a path the Windows host can open: `/mnt/c/...` → `C:\...` + * (drvfs mount), any other absolute POSIX path → `\\wsl.localhost\<distro>\...`. + * Non-absolute or already-Windows paths pass through. + */ +export function wslPosixToWindowsAccessible(posixPath: string, distro: string = resolveDefaultWslDistro()): string { + const value = String(posixPath || '').trim() + const normalized = value.replace(/\\/g, '/') + + if (!normalized.startsWith('/')) { + return value + } + + const mount = normalized.match(WSL_MOUNT_RE) + + if (mount) { + const tail = (mount[2] || '').replace(/\//g, '\\') + + return tail ? `${mount[1].toUpperCase()}:\\${tail}` : `${mount[1].toUpperCase()}:\\` + } + + const relative = normalized.replace(/^\/+/, '').replace(/\//g, '\\') + + return `${wslUncBase(distro)}\\${relative}` +} + +/** Native folder dialog `defaultPath`: open a WSL cwd in the Windows picker. */ +export function resolvePickerDefaultPath( + defaultPath: string | undefined, + distro: string = resolveDefaultWslDistro() +): string | undefined { + if (!defaultPath) { + return undefined + } + + const value = String(defaultPath).trim() + + return value.startsWith('/') && !WIN_DRIVE_RE.test(value) ? wslPosixToWindowsAccessible(value, distro) : defaultPath +} + +/** fs read path: on Windows, make a WSL cwd readable via its UNC / drive form. */ +export function resolveLocalReadPath(dirPath: string, distro: string = resolveDefaultWslDistro()): string { + const value = String(dirPath || '').trim() + + return IS_WINDOWS && value.startsWith('/') && !WIN_DRIVE_RE.test(value) + ? wslPosixToWindowsAccessible(value, distro) + : value +} diff --git a/apps/desktop/electron/zoom.cjs b/apps/desktop/electron/zoom.cjs deleted file mode 100644 index 41477f41b420..000000000000 --- a/apps/desktop/electron/zoom.cjs +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Pure helpers for window zoom. The main process owns webContents.setZoomLevel, - * so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel - * through this one clamped scale. Percent is the user-facing unit (100 = the - * default size); Chromium's internal unit is the zoom level, where - * factor = 1.2 ^ level. - */ - -const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' - -const ZOOM_FACTOR_BASE = 1.2 -const MIN_ZOOM_LEVEL = -9 -const MAX_ZOOM_LEVEL = 9 - -function clampZoomLevel(value) { - if (!Number.isFinite(value)) return 0 - return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) -} - -function zoomLevelToPercent(level) { - return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100) -} - -function percentToZoomLevel(percent) { - if (!Number.isFinite(percent) || percent <= 0) return 0 - return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) -} - -module.exports = { - ZOOM_STORAGE_KEY, - clampZoomLevel, - percentToZoomLevel, - zoomLevelToPercent -} diff --git a/apps/desktop/electron/zoom.test.cjs b/apps/desktop/electron/zoom.test.cjs deleted file mode 100644 index da104a526303..000000000000 --- a/apps/desktop/electron/zoom.test.cjs +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Unit tests for the pure zoom helpers: clamping garbage input, the - * percent <-> zoom-level conversion the settings UI relies on, and the - * roundtrip stability of the preset percentages. - */ - -const test = require('node:test') -const assert = require('node:assert/strict') - -const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs') - -test('storage key stays stable so persisted zoom survives upgrades', () => { - assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel') -}) - -test('clampZoomLevel rejects garbage and enforces bounds', () => { - assert.equal(clampZoomLevel(NaN), 0) - assert.equal(clampZoomLevel(Infinity), 0) - assert.equal(clampZoomLevel(undefined), 0) - assert.equal(clampZoomLevel('2'), 0) - assert.equal(clampZoomLevel(0.3), 0.3) - assert.equal(clampZoomLevel(-42), -9) - assert.equal(clampZoomLevel(42), 9) -}) - -test('level 0 is exactly 100 percent', () => { - assert.equal(zoomLevelToPercent(0), 100) - assert.equal(percentToZoomLevel(100), 0) -}) - -test('percentToZoomLevel rejects garbage', () => { - assert.equal(percentToZoomLevel(NaN), 0) - assert.equal(percentToZoomLevel(0), 0) - assert.equal(percentToZoomLevel(-50), 0) - assert.equal(percentToZoomLevel(undefined), 0) -}) - -test('preset percentages roundtrip within rounding', () => { - for (const percent of [90, 100, 110, 125, 150, 175]) { - assert.equal(zoomLevelToPercent(percentToZoomLevel(percent)), percent) - } -}) - -test('conversion is monotonic across the preset range', () => { - const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel) - for (let i = 1; i < levels.length; i++) { - assert.ok(levels[i] > levels[i - 1]) - } -}) - -test('extreme percentages clamp to the level bounds', () => { - assert.equal(percentToZoomLevel(1), -9) - assert.equal(percentToZoomLevel(1_000_000), 9) -}) diff --git a/apps/desktop/electron/zoom.test.ts b/apps/desktop/electron/zoom.test.ts new file mode 100644 index 000000000000..d0a1517d38e5 --- /dev/null +++ b/apps/desktop/electron/zoom.test.ts @@ -0,0 +1,160 @@ +/** + * Unit tests for the pure zoom helpers: clamping garbage input, the + * percent <-> zoom-level conversion the settings UI relies on, and the + * roundtrip stability of the preset percentages. + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + applyZoomLevel, + clampZoomLevel, + installZoomReassertOnWindowEvents, + percentToZoomLevel, + ZOOM_REASSERT_WINDOW_EVENTS, + ZOOM_STORAGE_KEY, + zoomLevelToPercent, + zoomWiringForWindowKind +} from './zoom' + +test('storage key stays stable so persisted zoom survives upgrades', () => { + assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel') +}) + +test('clampZoomLevel rejects garbage and enforces bounds', () => { + assert.equal(clampZoomLevel(NaN), 0) + assert.equal(clampZoomLevel(Infinity), 0) + assert.equal(clampZoomLevel(undefined), 0) + assert.equal(clampZoomLevel('2'), 0) + assert.equal(clampZoomLevel(0.3), 0.3) + assert.equal(clampZoomLevel(-42), -9) + assert.equal(clampZoomLevel(42), 9) +}) + +test('level 0 is exactly 100 percent', () => { + assert.equal(zoomLevelToPercent(0), 100) + assert.equal(percentToZoomLevel(100), 0) +}) + +test('percentToZoomLevel rejects garbage', () => { + assert.equal(percentToZoomLevel(NaN), 0) + assert.equal(percentToZoomLevel(0), 0) + assert.equal(percentToZoomLevel(-50), 0) + assert.equal(percentToZoomLevel(undefined), 0) +}) + +test('preset percentages roundtrip within rounding', () => { + for (const percent of [90, 100, 110, 125, 150, 175]) { + assert.equal(zoomLevelToPercent(percentToZoomLevel(percent)), percent) + } +}) + +test('conversion is monotonic across the preset range', () => { + const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel) + + for (let i = 1; i < levels.length; i++) { + assert.ok(levels[i] > levels[i - 1]) + } +}) + +test('extreme percentages clamp to the level bounds', () => { + assert.equal(percentToZoomLevel(1), -9) + assert.equal(percentToZoomLevel(1_000_000), 9) +}) + +test('installZoomReassertOnWindowEvents wires show and restore', () => { + const handlers = new Map() + + const win = { + isDestroyed: () => false, + on(event, listener) { + handlers.set(event, listener) + } + } + + let calls = 0 + installZoomReassertOnWindowEvents(win, () => { + calls += 1 + }) + + assert.deepEqual([...handlers.keys()], [...ZOOM_REASSERT_WINDOW_EVENTS]) + handlers.get('show')() + handlers.get('restore')() + assert.equal(calls, 2) +}) + +test('installZoomReassertOnWindowEvents skips destroyed windows', () => { + const handlers = new Map() + let destroyed = false + + const win = { + isDestroyed: () => destroyed, + on(event, listener) { + handlers.set(event, listener) + } + } + + let calls = 0 + installZoomReassertOnWindowEvents(win, () => { + calls += 1 + }) + destroyed = true + handlers.get('show')() + assert.equal(calls, 0) +}) + +// Zoom-wiring contract: chat windows keep global UI zoom, the pet overlay +// opts out. Tested via the extracted config — no source-text regex. +test('chat windows opt into zoom', () => { + assert.deepEqual(zoomWiringForWindowKind('chat'), { zoom: true }) +}) + +test('pet overlay opts out of zoom', () => { + assert.deepEqual(zoomWiringForWindowKind('petOverlay'), { zoom: false }) +}) + +test('unknown window kinds default to chat (zoom enabled)', () => { + assert.deepEqual(zoomWiringForWindowKind('unknown'), { zoom: true }) + assert.deepEqual(zoomWiringForWindowKind(undefined), { zoom: true }) +}) + +// The UI Scale settings control drifts out of sync after a restart when zoom +// is applied to the window but the renderer is never told: its $zoomPercent +// store (see store/zoom.ts) only updates from zoom.get() (once, on load) and +// 'hermes:zoom:changed' events. applyZoomLevel is the single funnel every zoom +// path (user set, restore-on-load, lifecycle re-assert) shares, so applying a +// level always notifies — the regression can't come back by forgetting a send. +function fakeWebContents() { + const calls: Array<[string, ...unknown[]]> = [] + + return { + calls, + setZoomLevel: (level: number) => calls.push(['setZoomLevel', level]), + send: (channel: string, payload: unknown) => calls.push(['send', channel, payload]) + } +} + +test('applyZoomLevel applies the level then notifies the renderer', () => { + const wc = fakeWebContents() + const applied = applyZoomLevel(wc, 3) + + assert.equal(applied, 3) + assert.deepEqual(wc.calls, [ + ['setZoomLevel', 3], + ['send', 'hermes:zoom:changed', { level: 3, percent: zoomLevelToPercent(3) }] + ]) +}) + +test('applyZoomLevel clamps garbage before applying and notifying', () => { + const wc = fakeWebContents() + const applied = applyZoomLevel(wc, 999) + const clamped = clampZoomLevel(999) + + assert.equal(applied, clamped) + assert.deepEqual(wc.calls, [ + ['setZoomLevel', clamped], + ['send', 'hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }] + ]) +}) diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts new file mode 100644 index 000000000000..baf8ff48b70e --- /dev/null +++ b/apps/desktop/electron/zoom.ts @@ -0,0 +1,86 @@ +/** + * Pure helpers for window zoom. The main process owns webContents.setZoomLevel, + * so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel + * through this one clamped scale. Percent is the user-facing unit (100 = the + * default size); Chromium's internal unit is the zoom level, where + * factor = 1.2 ^ level. + */ + +export const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' + +const ZOOM_FACTOR_BASE = 1.2 +const MIN_ZOOM_LEVEL = -9 +const MAX_ZOOM_LEVEL = 9 + +export function clampZoomLevel(value) { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) +} + +export function zoomLevelToPercent(level) { + return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100) +} + +export function percentToZoomLevel(percent) { + if (!Number.isFinite(percent) || percent <= 0) { + return 0 + } + + return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) +} + +/** + * Apply a clamped zoom level to a webContents AND notify the renderer, in that + * order. Every path that changes zoom (user action, restore-on-load, lifecycle + * re-assert) funnels through here so the settings UI Scale control can never + * drift from the actually-applied level — the bug where restore set the level + * but forgot to emit 'hermes:zoom:changed', leaving the control stuck at 100%. + * Returns the clamped level so callers can persist it. + */ +export function applyZoomLevel(webContents, level) { + const clamped = clampZoomLevel(level) + webContents.setZoomLevel(clamped) + webContents.send('hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }) + + return clamped +} + +// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized +// and restored. Re-apply the persisted level on these lifecycle transitions. +export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore'] + +export function installZoomReassertOnWindowEvents(win, reassert) { + if (!win?.on) { + return + } + + for (const event of ZOOM_REASSERT_WINDOW_EVENTS) { + win.on(event, () => { + if (win.isDestroyed?.()) { + return + } + + reassert() + }) + } +} + +/** + * Zoom-wiring decision per window kind. Chat windows (main + session) keep + * global UI zoom; the pet overlay opts out because it sizes its own OS window + * to the sprite and inheriting zoom would crop it. + * + * Extracted so the "pet opts out, everything else opts in" contract is + * unit-testable without booting a BrowserWindow or reading source. + */ +export const ZOOM_WINDOW_CONFIG = { + chat: { zoom: true }, + petOverlay: { zoom: false } +} as const + +export function zoomWiringForWindowKind(kind) { + return ZOOM_WINDOW_CONFIG[kind] ?? ZOOM_WINDOW_CONFIG.chat +} diff --git a/apps/desktop/eslint.config.mjs b/apps/desktop/eslint.config.mjs index 069a0056bbb9..61abc0c90a0d 100644 --- a/apps/desktop/eslint.config.mjs +++ b/apps/desktop/eslint.config.mjs @@ -1,119 +1,43 @@ -import js from '@eslint/js' -import typescriptEslint from '@typescript-eslint/eslint-plugin' -import typescriptParser from '@typescript-eslint/parser' -import perfectionist from 'eslint-plugin-perfectionist' -import reactPlugin from 'eslint-plugin-react' -import hooksPlugin from 'eslint-plugin-react-hooks' -import unusedImports from 'eslint-plugin-unused-imports' +import shared from '../../eslint.config.shared.mjs' import globals from 'globals' -const noopRule = { - meta: { schema: [], type: 'problem' }, - create: () => ({}) -} - -const customRules = { - rules: { - 'no-process-cwd': noopRule, - 'no-process-env-top-level': noopRule, - 'no-sync-fs': noopRule, - 'no-top-level-dynamic-import': noopRule, - 'no-top-level-side-effects': noopRule - } -} - export default [ + ...shared, { - ignores: ['**/node_modules/**', '**/dist/**', 'src/**/*.js'] - }, - js.configs.recommended, - { + // Desktop is an Electron renderer — it legitimately uses browser globals + // (window, document, etc). Re-add them here; the shared config omits + // globals.browser so terminal-only workspaces (ui-tui) don't get them. files: ['**/*.{ts,tsx}'], languageOptions: { globals: { ...globals.browser, ...globals.node - }, - parser: typescriptParser, - parserOptions: { - ecmaFeatures: { jsx: true }, - ecmaVersion: 'latest', - sourceType: 'module' } - }, - plugins: { - '@typescript-eslint': typescriptEslint, - 'custom-rules': customRules, - perfectionist, - react: reactPlugin, - 'react-hooks': hooksPlugin, - 'unused-imports': unusedImports - }, + } + }, + { + // THE PLUGIN FENCE: plugins speak @hermes/plugin-sdk (+ react), never `@/…` + // internals — the same isolation a runtime-fetched published plugin gets, + // enforced on bundled ones so the SDK surface stays honest and sufficient. + files: ['src/plugins/**/*.{ts,tsx}'], rules: { - '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], - '@typescript-eslint/no-unused-vars': 'off', - curly: ['error', 'all'], - 'no-fallthrough': ['error', { allowEmptyCase: true }], - 'no-undef': 'off', - 'no-unused-vars': 'off', - 'padding-line-between-statements': [ - 1, - { - blankLine: 'always', - next: [ - 'block-like', - 'block', - 'return', - 'if', - 'class', - 'continue', - 'debugger', - 'break', - 'multiline-const', - 'multiline-let' - ], - prev: '*' - }, - { - blankLine: 'always', - next: '*', - prev: ['case', 'default', 'multiline-const', 'multiline-let', 'multiline-block-like'] - }, - { blankLine: 'never', next: ['block', 'block-like'], prev: ['case', 'default'] }, - { blankLine: 'always', next: ['block', 'block-like'], prev: ['block', 'block-like'] }, - { blankLine: 'always', next: ['empty'], prev: 'export' }, - { blankLine: 'never', next: 'iife', prev: ['block', 'block-like', 'empty'] } - ], - 'perfectionist/sort-exports': ['error', { order: 'asc', type: 'natural' }], - 'perfectionist/sort-imports': [ + 'no-restricted-imports': [ 'error', { - groups: ['side-effect', 'builtin', 'external', 'internal', 'parent', 'sibling', 'index'], - order: 'asc', - type: 'natural' + patterns: [ + { + group: ['@/*', '../*', '@hermes/shared'], + message: 'Plugins import only @hermes/plugin-sdk (and react). Missing something? Add it to the SDK.' + } + ] } - ], - 'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }], - 'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }], - 'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }], - 'react-hooks/exhaustive-deps': 'warn', - 'react-hooks/rules-of-hooks': 'error', - 'unused-imports/no-unused-imports': 'error' - }, - settings: { - react: { version: 'detect' } + ] } }, { - files: ['**/*.js', '**/*.cjs'], - ignores: ['**/node_modules/**', '**/dist/**'], - languageOptions: { - ecmaVersion: 'latest', - globals: { ...globals.node }, - sourceType: 'commonjs' + files: ['**/*.test.tsx'], + rules: { + 'no-restricted-globals': ['warn', 'document'] } - }, - { - ignores: ['*.config.*'] } ] diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c3daba00e2b3..556a05b6d897 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -6,22 +6,23 @@ "description": "Native desktop shell for Hermes Agent.", "author": "Nous Research", "type": "module", - "main": "electron/main.cjs", + "main": "dist/electron-main.mjs", "engines": { "node": "^20.19.0 || >=22.12.0" }, "scripts": { "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"", "dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev", - "dev:renderer": "node scripts/assert-root-install.cjs && vite --host 127.0.0.1 --port 5174", - "dev:electron": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", - "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", - "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", + "dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174", + "dev:electron": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", + "profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", + "profile:main:cpu": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", - "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild", - "postbuild": "node scripts/assert-dist-built.cjs", - "prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs", - "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs", + "prebuild": "tsc -b . --clean", + "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs", + "postbuild": "node scripts/assert-dist-built.mjs", + "prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs", + "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs", "pack": "npm run build && npm run builder -- --dir", "dist": "npm run build && npm run builder", "dist:mac": "npm run build && npm run builder -- --mac", @@ -37,18 +38,20 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/zoom.test.cjs electron/windows-hermes-resolution.test.cjs electron/oauth-session-request.test.cjs", - "typecheck": "tsc -p . --noEmit", + "typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", - "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'", + "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'", "fix": "npm run lint:fix && npm run fmt", - "test:ui": "vitest run --environment jsdom", - "preview": "node scripts/assert-root-install.cjs && vite preview --host 127.0.0.1 --port 4174" + "test:ui": "vitest run --project ui", + "test:desktop:platforms": "vitest run --project electron", + "test": "vitest run", + "preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174", + "check": "npm run typecheck && npm run test && npm run test:desktop:all && npm run build" }, "dependencies": { - "@assistant-ui/react": "^0.12.28", - "@assistant-ui/react-streamdown": "^0.1.11", + "@assistant-ui/react": "^0.14.23", + "@assistant-ui/react-streamdown": "^0.3.4", "@audiowave/react": "^0.6.2", "@chenglou/pretext": "^0.0.6", "@codemirror/commands": "^6.10.4", @@ -117,12 +120,13 @@ "web-haptics": "^0.0.6" }, "devDependencies": { + "@electron/rebuild": "^4.0.6", "@eslint/js": "^9.39.4", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", "@types/d3-force": "^3.0.10", "@types/hast": "^3.0.4", - "@types/node": "^24.13.2", + "@types/node": "^22.20.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.59.1", @@ -132,6 +136,7 @@ "cross-env": "^10.1.0", "electron": "40.10.2", "electron-builder": "^26.8.1", + "esbuild": "^0.28.1", "eslint": "^9.39.4", "eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-react": "^7.37.5", @@ -141,6 +146,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.3", "rcedit": "^5.0.2", + "tsx": "^4.22.4", "typescript": "^6.0.3", "vite": "^8.0.10", "vitest": "^4.1.5", @@ -167,29 +173,24 @@ "files": [ "dist/**", "assets/**", - "electron/**", "public/**", "package.json" ], - "beforeBuild": "scripts/before-build.cjs", - "beforePack": "scripts/before-pack.cjs", - "afterPack": "scripts/after-pack.cjs", + "beforeBuild": "scripts/before-build.mjs", + "beforePack": "scripts/before-pack.mjs", + "afterPack": "scripts/after-pack.mjs", "extraResources": [ { "from": "build/install-stamp.json", "to": "install-stamp.json" }, - { - "from": "build/native-deps", - "to": "native-deps" - }, { "from": "assets/icon.ico", "to": "icon.ico" } ], "asar": true, - "afterSign": "scripts/notarize.cjs", + "afterSign": "scripts/notarize.mjs", "asarUnpack": [ "**/*.node", "**/prebuilds/**", diff --git a/apps/desktop/scripts/after-pack.cjs b/apps/desktop/scripts/after-pack.mjs similarity index 81% rename from apps/desktop/scripts/after-pack.cjs rename to apps/desktop/scripts/after-pack.mjs index f81262d28ae6..509cbb424814 100644 --- a/apps/desktop/scripts/after-pack.cjs +++ b/apps/desktop/scripts/after-pack.mjs @@ -1,8 +1,8 @@ /** - * after-pack.cjs — electron-builder afterPack hook. + * after-pack.mjs — electron-builder afterPack hook. * * Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via - * rcedit (delegated to set-exe-identity.cjs). This runs for EVERY packed build + * rcedit (delegated to set-exe-identity.mjs). This runs for EVERY packed build * — first install, `hermes desktop`, the installer's --update rebuild, and a * dev's manual `npm run pack` — so the branded exe can never silently revert * to the stock "Electron" icon/name (the bug when the stamp lived only in @@ -19,18 +19,18 @@ * - packager.appInfo.productFilename: the exe basename (e.g. 'Hermes') */ -const path = require('node:path') +import path from 'node:path' -const { stampExeIdentity } = require('./set-exe-identity.cjs') +import { stampExeIdentity } from './set-exe-identity.mjs' -exports.default = async function afterPack(context) { +export default async function afterPack(context) { if (context.electronPlatformName !== 'win32') { return } const productName = context.packager?.appInfo?.productFilename || 'Hermes' const exe = path.join(context.appOutDir, `${productName}.exe`) - const desktopRoot = path.resolve(__dirname, '..') + const desktopRoot = path.resolve(import.meta.dirname, '..') try { await stampExeIdentity(exe, desktopRoot) diff --git a/apps/desktop/scripts/assert-dist-built.cjs b/apps/desktop/scripts/assert-dist-built.mjs similarity index 74% rename from apps/desktop/scripts/assert-dist-built.cjs rename to apps/desktop/scripts/assert-dist-built.mjs index 8eea50f45a3e..3715eba4e223 100644 --- a/apps/desktop/scripts/assert-dist-built.cjs +++ b/apps/desktop/scripts/assert-dist-built.mjs @@ -1,5 +1,3 @@ -"use strict" - // Build-time guard: refuse to hand a half-built renderer to electron-builder. // // `npm run pack` / `npm run dist*` are `npm run build && npm run builder`. @@ -13,31 +11,32 @@ // inherits it. It fails loud and early instead of shipping a broken bundle. // See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404). -const fs = require("fs") -const path = require("path") +import { existsSync, statSync, readdirSync } from "fs" +import { join, resolve } from "path" +import { isMain } from "./utils.mjs" // Pure check — returns { ok: true } or { ok: false, error: "..." }. // Kept side-effect-free so it can be unit tested without spawning a process. -function checkDistBuilt(distDir) { - if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) { +export function checkDistBuilt(distDir) { + if (!existsSync(distDir) || !statSync(distDir).isDirectory()) { return { ok: false, error: `no dist directory at ${distDir}` } } - const indexHtml = path.join(distDir, "index.html") - if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) { + const indexHtml = join(distDir, "index.html") + if (!existsSync(indexHtml) || !statSync(indexHtml).isFile()) { return { ok: false, error: `dist/index.html is missing at ${indexHtml}` } } - if (fs.statSync(indexHtml).size === 0) { + if (statSync(indexHtml).size === 0) { return { ok: false, error: `dist/index.html is empty at ${indexHtml}` } } // index.html alone isn't enough — vite emits hashed JS into dist/assets. // An index.html with no script bundle still blank-pages. - const assetsDir = path.join(distDir, "assets") + const assetsDir = join(distDir, "assets") const hasAssets = - fs.existsSync(assetsDir) && - fs.statSync(assetsDir).isDirectory() && - fs.readdirSync(assetsDir).some(name => name.endsWith(".js")) + existsSync(assetsDir) && + statSync(assetsDir).isDirectory() && + readdirSync(assetsDir).some(name => name.endsWith(".js")) if (!hasAssets) { return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` } } @@ -46,8 +45,8 @@ function checkDistBuilt(distDir) { } function main() { - const desktopRoot = path.resolve(__dirname, "..") - const distDir = path.join(desktopRoot, "dist") + const desktopRoot = resolve(import.meta.dirname, "..") + const distDir = join(desktopRoot, "dist") const result = checkDistBuilt(distDir) if (!result.ok) { @@ -63,8 +62,8 @@ function main() { console.log("✓ assert-dist-built: dist/index.html + assets present") } -if (require.main === module) { +if (isMain(import.meta.url)) { main() } -module.exports = { checkDistBuilt } +export default { checkDistBuilt } diff --git a/apps/desktop/scripts/assert-dist-built.test.cjs b/apps/desktop/scripts/assert-dist-built.test.mjs similarity index 91% rename from apps/desktop/scripts/assert-dist-built.test.cjs rename to apps/desktop/scripts/assert-dist-built.test.mjs index 5121762469a8..7c99ac76d0d2 100644 --- a/apps/desktop/scripts/assert-dist-built.test.cjs +++ b/apps/desktop/scripts/assert-dist-built.test.mjs @@ -1,10 +1,10 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test } from 'vitest' -const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs') +import { checkDistBuilt } from '../scripts/assert-dist-built.mjs' function makeDist(extra) { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-')) diff --git a/apps/desktop/scripts/assert-root-install.cjs b/apps/desktop/scripts/assert-root-install.cjs deleted file mode 100644 index 26433ca9be7e..000000000000 --- a/apps/desktop/scripts/assert-root-install.cjs +++ /dev/null @@ -1,13 +0,0 @@ -"use strict" - -const fs = require("fs") -const path = require("path") - -const root = path.resolve(__dirname, "..", "..", "..") - -try { - fs.accessSync(path.join(root, "node_modules", "vite", "package.json")) -} catch { - console.error(`Run from repo root: cd ${root} && npm ci`) - process.exit(1) -} diff --git a/apps/desktop/scripts/assert-root-install.mjs b/apps/desktop/scripts/assert-root-install.mjs new file mode 100644 index 000000000000..5dc1d51bdcd9 --- /dev/null +++ b/apps/desktop/scripts/assert-root-install.mjs @@ -0,0 +1,11 @@ +import { accessSync } from "fs" +import { resolve, join } from "path" + +const root = resolve(import.meta.dirname, "..", "..", "..") + +try { + accessSync(join(root, "node_modules", "vite", "package.json")) +} catch { + console.error(`Run from repo root: cd ${root} && npm ci`) + process.exit(1) +} diff --git a/apps/desktop/scripts/before-build.cjs b/apps/desktop/scripts/before-build.mjs similarity index 89% rename from apps/desktop/scripts/before-build.cjs rename to apps/desktop/scripts/before-build.mjs index 673aca380d37..e9a1d843ae58 100644 --- a/apps/desktop/scripts/before-build.cjs +++ b/apps/desktop/scripts/before-build.mjs @@ -4,8 +4,8 @@ * avoids workspace dependency graph explosions and keeps packaging * deterministic across environments. The Hermes Agent Python payload is no * longer bundled; the Electron app fetches it at first launch via - * `install.ps1`'s stage protocol (Windows). See `electron/main.cjs`. + * `install.ps1`'s stage protocol (Windows). See `electron/main.ts`. */ -module.exports = async function beforeBuild() { +export default async function beforeBuild() { return false } diff --git a/apps/desktop/scripts/before-pack.cjs b/apps/desktop/scripts/before-pack.mjs similarity index 54% rename from apps/desktop/scripts/before-pack.cjs rename to apps/desktop/scripts/before-pack.mjs index 7ef9bcfadc8a..8b2359dfba63 100644 --- a/apps/desktop/scripts/before-pack.cjs +++ b/apps/desktop/scripts/before-pack.mjs @@ -1,10 +1,10 @@ -'use strict' - /** - * before-pack.cjs — electron-builder beforePack hook. + * before-pack.mjs — electron-builder beforePack hook. * - * Removes any stale unpacked app directory (`appOutDir`) before - * electron-builder stages the Electron binaries into it. + * Two responsibilities: + * + * 1. Removes any stale unpacked app directory (`appOutDir`) before + * electron-builder stages the Electron binaries into it. * * WHY THIS EXISTS * --------------- @@ -41,30 +41,41 @@ * resolve rather than throw — worst case electron-builder hits the original * ENOENT, which is no worse than not having this hook at all. * + * 2. Re-stages node-pty's native files for the ACTUAL target platform/arch + * of this pack. `npm run build` already staged node-pty once for the + * host machine (see scripts/stage-native-deps.mjs), which is correct for + * single-arch builds matching the host. But electron-builder can target + * a different arch than the host (cross-build), or pack multiple archs + * from one `npm run build` (e.g. `dist:mac` => x64 + arm64). Only this + * hook knows the real per-target arch, via `context.arch` / + * `context.electronPlatformName` — so it re-stages on top of whatever + * `npm run build` left behind, per target, right before files are read + * for packing. + * * electron-builder passes a context with: * - appOutDir: the unpacked app directory about to be staged * - electronPlatformName: 'win32' | 'darwin' | 'linux' + * - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal) */ +import { existsSync, rmSync } from 'node:fs' +import { Arch } from 'electron-builder' +import { stageNodePty } from './stage-native-deps.mjs' -const fs = require('node:fs') - -function cleanStaleAppOutDir(appOutDir) { +export function cleanStaleAppOutDir(appOutDir) { if (!appOutDir || typeof appOutDir !== 'string') { return false } - if (!fs.existsSync(appOutDir)) { + if (!existsSync(appOutDir)) { return false } // Recursive + force so a half-written tree (read-only bits, partial files) // can't block the wipe. retry/maxRetries rides out transient EBUSY on // Windows where an AV/indexer may briefly hold a handle. - fs.rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) return true } -exports.cleanStaleAppOutDir = cleanStaleAppOutDir - -exports.default = async function beforePack(context) { +export default async function beforePack(context) { const appOutDir = context && context.appOutDir try { if (cleanStaleAppOutDir(appOutDir)) { @@ -75,4 +86,26 @@ exports.default = async function beforePack(context) { // directory (permissions, mount) is still diagnosable. console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`) } -} + + try { + const platform = context && context.electronPlatformName + const archName = context && typeof context.arch === 'number' ? Arch[context.arch] : undefined + if (platform && archName) { + if (archName === 'universal') { + console.warn( + '[before-pack] target arch is "universal" — node-pty has no universal prebuild; ' + + 'staged binary will be whichever single-arch copy npm run build left behind. ' + + 'lipo-merge x64/arm64 .node files manually if you need a true universal build.' + ) + } else { + await stageNodePty({ platform, arch: archName }) + console.log(`[before-pack] re-staged node-pty for target ${platform}-${archName}`) + } + } + } catch (err) { + // This one SHOULD fail the build — a missing/wrong native binary for the + // target arch means a broken package shipped to users, which is worse + // than a build that fails loudly here. + throw new Error(`[before-pack] failed to stage node-pty for this target: ${err.message}`) + } +} \ No newline at end of file diff --git a/apps/desktop/scripts/before-pack.test.cjs b/apps/desktop/scripts/before-pack.test.mjs similarity index 86% rename from apps/desktop/scripts/before-pack.test.cjs rename to apps/desktop/scripts/before-pack.test.mjs index 763922aa6f8e..44adf9616187 100644 --- a/apps/desktop/scripts/before-pack.test.cjs +++ b/apps/desktop/scripts/before-pack.test.mjs @@ -1,10 +1,10 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const test = require('node:test') +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test } from 'vitest' -const { cleanStaleAppOutDir } = require('../scripts/before-pack.cjs') +import beforePack, { cleanStaleAppOutDir } from '../scripts/before-pack.mjs' test('cleanStaleAppOutDir removes a populated unpacked directory', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) @@ -45,7 +45,6 @@ test('cleanStaleAppOutDir ignores empty or invalid input', () => { }) test('beforePack default export resolves even when cleanup throws', async () => { - const { default: beforePack } = require('../scripts/before-pack.cjs') // A directory path that rmSync can't remove is simulated by passing a // context whose appOutDir is a file the hook will try (and be allowed) to // remove; the contract under test is that the hook never rejects. diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs new file mode 100644 index 000000000000..316d2ce1b185 --- /dev/null +++ b/apps/desktop/scripts/bundle-electron-main.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// bundle-electron-main.mjs — bundles electron/main.ts and electron/preload.ts +// into self-contained js files in dist/ so the packaged app doesn't need +// node_modules/ or tsx at runtime. +// +// Output: +// dist/electron-main.mjs (MJS bundle — entry point for packaged app) +// dist/electron-preload.js (CJS bundle — loaded via BrowserWindow preload) +// +// `electron` and `node-pty` are external (provided by the runtime / staged +// separately via stage-native-deps). +import { build } from 'esbuild' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdirSync } from 'node:fs' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = resolve(here, '..') +const distDir = resolve(root, 'dist') +mkdirSync(distDir, { recursive: true }) + +const mainEntry = resolve(root, 'electron/main.ts') +const mainOut = resolve(distDir, 'electron-main.mjs') +const preloadEntry = resolve(root, 'electron/preload.ts') +const preloadOut = resolve(distDir, 'electron-preload.js') + +const external = ['electron', 'node-pty', 'fs'] +// Production bundles bake packaged=true so unpackaged `electron .` still +// behaves like a packaged build. Dev bundles (`--dev`) leave the env alone +// so HERMES_DESKTOP_DEV_SERVER / source-tree resolution keep working. +const isDev = process.argv.includes('--dev') +const define = isDev + ? {} + : { 'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true) } + +// Bundle main.ts → dist/electron-main.mjs +await build({ + entryPoints: [mainEntry], + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + outfile: mainOut, + external, + banner: { + js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);", + }, + define, + logLevel: 'info', +}) +console.log(`bundled ${mainOut}${isDev ? ' (dev)' : ''}`) + +// Bundle preload.ts → dist/electron-preload.js +await build({ + entryPoints: [preloadEntry], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + outfile: preloadOut, + external, + define, + logLevel: 'info', +}) +console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`) diff --git a/apps/desktop/scripts/notarize-artifact.cjs b/apps/desktop/scripts/notarize-artifact.mjs similarity index 83% rename from apps/desktop/scripts/notarize-artifact.cjs rename to apps/desktop/scripts/notarize-artifact.mjs index 89a4901c5ccd..e7ea2f024ff2 100644 --- a/apps/desktop/scripts/notarize-artifact.cjs +++ b/apps/desktop/scripts/notarize-artifact.mjs @@ -1,7 +1,7 @@ -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { execFile } = require('node:child_process') +import { existsSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFile } from 'node:child_process' function run(command, args) { return new Promise((resolve, reject) => { @@ -26,7 +26,7 @@ function resolveApiKeyPath(rawValue) { const value = String(rawValue || '').trim() if (!value) return { keyPath: '', cleanup: () => {} } - if (fs.existsSync(value)) { + if (existsSync(value)) { return { keyPath: value, cleanup: () => {} } } @@ -34,17 +34,17 @@ function resolveApiKeyPath(rawValue) { throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content') } - const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`) - fs.writeFileSync(tempPath, value, 'utf8') + const tempPath = join(tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`) + writeFileSync(tempPath, value, 'utf8') return { keyPath: tempPath, - cleanup: () => fs.rmSync(tempPath, { force: true }) + cleanup: () => rmSync(tempPath, { force: true }) } } async function main() { const artifactPath = process.argv[2] - if (!artifactPath || !fs.existsSync(artifactPath)) { + if (!artifactPath || !existsSync(artifactPath)) { throw new Error(`Missing artifact to notarize: ${artifactPath || '(none)'}`) } diff --git a/apps/desktop/scripts/notarize.cjs b/apps/desktop/scripts/notarize.mjs similarity index 93% rename from apps/desktop/scripts/notarize.cjs rename to apps/desktop/scripts/notarize.mjs index 1508e18e8034..49294469e553 100644 --- a/apps/desktop/scripts/notarize.cjs +++ b/apps/desktop/scripts/notarize.mjs @@ -1,7 +1,7 @@ -const fs = require('node:fs') -const os = require('node:os') -const path = require('node:path') -const { execFile } = require('node:child_process') +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFile } from 'node:child_process' function run(command, args) { return new Promise((resolve, reject) => { @@ -49,7 +49,7 @@ function resolveApiKeyPath(rawValue) { } } -exports.default = async function notarize(context) { +export default async function notarize(context) { const { electronPlatformName, appOutDir, packager } = context if (electronPlatformName !== 'darwin') return diff --git a/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs b/apps/desktop/scripts/patch-electron-builder-mac-binary.mjs similarity index 96% rename from apps/desktop/scripts/patch-electron-builder-mac-binary.cjs rename to apps/desktop/scripts/patch-electron-builder-mac-binary.mjs index b88c281219fe..6cfa41f32ec5 100644 --- a/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs +++ b/apps/desktop/scripts/patch-electron-builder-mac-binary.mjs @@ -1,11 +1,11 @@ -const fs = require('node:fs') -const path = require('node:path') +import fs from 'node:fs' +import path from 'node:path' if (process.platform !== 'darwin') { process.exit(0) } -const desktopRoot = path.resolve(__dirname, '..') +const desktopRoot = path.resolve(import.meta.dirname, '..') const repoRoot = path.resolve(desktopRoot, '..', '..') const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js') diff --git a/apps/desktop/scripts/rebuild-native.mjs b/apps/desktop/scripts/rebuild-native.mjs new file mode 100644 index 000000000000..ddec5ea318e4 --- /dev/null +++ b/apps/desktop/scripts/rebuild-native.mjs @@ -0,0 +1,22 @@ +// rebuild-native.mjs +import { rebuild } from '@electron/rebuild' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { isMain } from './utils.mjs' +import packageJson from '../package.json' with { type: 'json' } +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +export async function rebuildNodePty({ arch = process.arch } = {}) { + await rebuild({ + buildPath: projectRoot, // where node_modules lives + electronVersion: packageJson.devDependencies.electron.replace('^', ''), + arch, + onlyModules: ['node-pty'], + force: true + }) +} + +if (isMain(import.meta.url)) { + const [arch] = process.argv.slice(2) + await rebuildNodePty({ arch }) +} diff --git a/apps/desktop/scripts/run-electron-builder.cjs b/apps/desktop/scripts/run-electron-builder.mjs similarity index 89% rename from apps/desktop/scripts/run-electron-builder.cjs rename to apps/desktop/scripts/run-electron-builder.mjs index 100d6c346e96..38e465612f9e 100644 --- a/apps/desktop/scripts/run-electron-builder.cjs +++ b/apps/desktop/scripts/run-electron-builder.mjs @@ -1,14 +1,15 @@ -"use strict" - // Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can // re-unpack a broken Electron.app; reusing the installed dist dodges that. // npm workspace hoisting is non-deterministic — require.resolve finds electron // wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let // electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR). -const fs = require("node:fs") -const path = require("node:path") -const { spawnSync } = require("node:child_process") +import fs from "node:fs" +import path from "node:path" +import { spawnSync } from "node:child_process" +import { createRequire } from "node:module" + +const require = createRequire(import.meta.url) function electronDistDir() { try { diff --git a/apps/desktop/scripts/set-exe-identity.cjs b/apps/desktop/scripts/set-exe-identity.mjs similarity index 70% rename from apps/desktop/scripts/set-exe-identity.cjs rename to apps/desktop/scripts/set-exe-identity.mjs index 129e1505bdab..4e19999c7546 100644 --- a/apps/desktop/scripts/set-exe-identity.cjs +++ b/apps/desktop/scripts/set-exe-identity.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// set-exe-identity.cjs — stamp the Hermes icon + version metadata onto the +// set-exe-identity.mjs — stamp the Hermes icon + version metadata onto the // built Hermes.exe using rcedit, completely decoupled from electron-builder's // signing path. // @@ -20,7 +20,7 @@ // // HOW IT RUNS // ----------- -// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.cjs), +// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.mjs), // so EVERY packed build — first install, `hermes desktop`, the installer's // --update rebuild, or a dev's manual `npm run pack` — gets a branded exe from // one place. Previously this stamp lived only in install.ps1, so the update @@ -28,40 +28,34 @@ // shipped a stock "Electron" exe. Keeping it in afterPack closes that gap. // // Also runnable standalone for ad-hoc re-stamping: -// node scripts/set-exe-identity.cjs <path-to-Hermes.exe> +// node scripts/set-exe-identity.mjs <path-to-Hermes.exe> // // Exits 0 on success, non-zero on failure when run as a CLI. As a hook, // stampExeIdentity() resolves on success and rejects on failure; the caller -// (after-pack.cjs) swallows the rejection so a stamp failure never fails an +// (after-pack.mjs) swallows the rejection so a stamp failure never fails an // otherwise-good build (worst case: stock icon, not a broken app). -const path = require('node:path') -const fs = require('node:fs') +import { resolve, join } from 'node:path' +import { existsSync } from 'node:fs' + +import { rcedit } from 'rcedit' + +import { isMain } from './utils.mjs' // Stamp the Hermes icon + identity onto `exe`. Resolves on success, throws on // failure. `desktopRoot` defaults to this script's package root so the icon and // the rcedit dependency resolve regardless of cwd. -async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..')) { - if (!exe || !fs.existsSync(exe)) { +async function stampExeIdentity(exe, desktopRoot = resolve(import.meta.dirname, '..')) { + if (!exe || !existsSync(exe)) { throw new Error(`target exe not found: ${exe}`) } // Icon lives at apps/desktop/assets/icon.ico - const icon = path.join(desktopRoot, 'assets', 'icon.ico') - if (!fs.existsSync(icon)) { + const icon = join(desktopRoot, 'assets', 'icon.ico') + if (!existsSync(icon)) { throw new Error(`icon not found: ${icon}`) } - // rcedit is a direct devDependency of apps/desktop, so it resolves whether - // we're run from the desktop dir or the repo root (workspace hoist). - // rcedit@5 exports a NAMED `rcedit` function (CommonJS: { rcedit }), not a - // default export. - const mod = require('rcedit') - const rcedit = typeof mod === 'function' ? mod : mod.rcedit - if (typeof rcedit !== 'function') { - throw new Error(`unexpected rcedit export shape: ${typeof mod} keys=${Object.keys(mod)}`) - } - console.log(`[set-exe-identity] stamping ${exe}`) console.log(`[set-exe-identity] icon: ${icon}`) @@ -78,13 +72,13 @@ async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..') console.log('[set-exe-identity] done — Hermes icon + identity stamped') } -module.exports = { stampExeIdentity } +export { stampExeIdentity } -// CLI entry point: `node scripts/set-exe-identity.cjs <exe>`. -if (require.main === module) { +// CLI entry point: `node scripts/set-exe-identity.mjs <exe>`. +if (isMain(import.meta.url)) { const exe = process.argv[2] if (!exe) { - console.error('[set-exe-identity] usage: set-exe-identity.cjs <path-to-exe>') + console.error('[set-exe-identity] usage: set-exe-identity.mjs <path-to-exe>') process.exit(2) } stampExeIdentity(exe).catch(err => { diff --git a/apps/desktop/scripts/stage-native-deps.cjs b/apps/desktop/scripts/stage-native-deps.cjs deleted file mode 100644 index ef68368dee76..000000000000 --- a/apps/desktop/scripts/stage-native-deps.cjs +++ /dev/null @@ -1,283 +0,0 @@ -'use strict' - -/** - * Stage native node-modules dependencies for electron-builder packaging. - * - * Workspace dedup hoists `node-pty` into the root `node_modules/`, which - * electron-builder's default file collector (when `files:` is explicitly set - * in package.json) cannot reach. The result: packaged builds ship with no - * .node binaries and PTY initialization fails at runtime ("PTY support is - * unavailable"). - * - * Rather than restructure the workspace dedup (would require nohoist / - * package.json shenanigans and risk breaking dev) or balloon the package - * with the whole node_modules tree, we copy ONLY the runtime-essential - * files of the native dep into apps/desktop/build/native-deps/ and ship - * THAT subtree via extraResources. main.cjs falls back to require()-ing - * from process.resourcesPath when the hoisted-root require fails. - * - * Runs as part of `npm run build`. Idempotent -- always re-stages on each - * build to pick up native binary updates. - * - * Layout note: upstream node-pty (microsoft/node-pty 1.x) is N-API based - * and ships its prebuilts under `prebuilds/<platform>-<arch>/` instead of - * `build/Release/`. Its runtime resolver (lib/utils.js) checks - * build/Release first and falls through to the per-arch prebuilds dir, so - * shipping only the latter is sufficient for packaged runs. Per-arch - * staging keeps the resource bundle lean -- we only need the target - * arch's prebuilt, not all of them. - */ - -const fs = require('node:fs') -const path = require('node:path') - -const APP_ROOT = path.resolve(__dirname, '..') -const REPO_ROOT = path.resolve(APP_ROOT, '..', '..') -const STAGE_ROOT = path.join(APP_ROOT, 'build', 'native-deps') - -// The target arch may be overridden by electron-builder via npm_config_arch -// (e.g. `npm run dist -- --arm64`); fall back to the build host's arch. -const TARGET_ARCH = process.env.npm_config_arch || process.arch -const TARGET_PLATFORM = process.platform - -// Modules to stage. The "from" path is the hoisted location in the workspace -// root; "to" is the layout we want inside build/native-deps/. The "include" -// globs (relative to "from") select the runtime-essential files. Anything -// outside the include list is left behind (source, deps/, scripts/, etc.). -const NATIVE_DEPS = [ - { - from: path.join(REPO_ROOT, 'node_modules', 'node-pty'), - to: path.join(STAGE_ROOT, 'node-pty'), - include: [ - 'package.json', - 'lib/*.js', - 'lib/**/*.js', - 'build/Release/*.node', - // Per-arch runtime payload. Explicit file types so we don't ship the - // ~25 MB of .pdb debug symbols that prebuild-install bundles for - // Windows crash analysis -- not used at runtime, would just bloat - // the installer. - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.node`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.dll`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.exe`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/spawn-helper`, - `prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/conpty/*` - ] - } -] - -// Pure-JS runtime dependencies that the packaged electron main require()s but -// that workspace dedup hoists into the repo-root node_modules -- out of reach -// of electron-builder's file collector, exactly like node-pty above. Unlike -// node-pty there is no native binary to select; we stage each package's whole -// directory into build/native-deps/vendor/node_modules/<name> so the dep's own -// internal require()s resolve against a real node_modules tree, and the -// requiring file (electron/git-review-ops.cjs) falls back to that path via -// process.resourcesPath when the normal require() fails. See issue #52735 -// (packaged app crashed at launch on `Cannot find module 'simple-git'`). -// -// The closure is resolved at stage time by walking dependencies + -// optionalDependencies, so a simple-git version bump that pulls in a new -// transitive dep can't silently re-introduce the crash. -// -// Layout note: the closure lands in build/native-deps/vendor/node_modules/, -// NOT build/native-deps/node_modules/. electron-builder's file collector -// hard-drops a `node_modules` directory that sits at the ROOT of an -// extraResources copy (app-builder-lib/out/util/filter.js: `if (relative === -// "node_modules") return false`), but keeps a NESTED one. Nesting under -// `vendor/` makes node_modules a subdirectory so it survives packing; the -// require() fallback in git-review-ops.cjs resolves the matching -// vendor/node_modules path. -const JS_DEP_ROOTS = ['simple-git'] -const JS_DEP_STAGE_ROOT = path.join(STAGE_ROOT, 'vendor', 'node_modules') - -function rmrf(target) { - fs.rmSync(target, { recursive: true, force: true }) -} - -function ensureDir(target) { - fs.mkdirSync(target, { recursive: true }) -} - -function walk(root) { - const results = [] - const stack = [root] - while (stack.length) { - const current = stack.pop() - let entries - try { - entries = fs.readdirSync(current, { withFileTypes: true }) - } catch { - continue - } - for (const entry of entries) { - const full = path.join(current, entry.name) - if (entry.isDirectory()) { - stack.push(full) - } else if (entry.isFile()) { - results.push(full) - } - } - } - return results -} - -// Match a relative path against simple ** and * glob patterns. Implementation -// is intentionally tiny -- the include lists are small and don't need full -// minimatch support. -function matchGlob(rel, pattern) { - const r = rel.replace(/\\/g, '/') - const re = new RegExp( - '^' + - pattern - .replace(/\\/g, '/') - .replace(/[.+^${}()|[\]\\]/g, '\\$&') - .replace(/\*\*/g, '__DOUBLE_STAR__') - .replace(/\*/g, '[^/]*') - .replace(/__DOUBLE_STAR__/g, '.*') + - '$' - ) - return re.test(r) -} - -function stageOne(spec) { - if (!fs.existsSync(spec.from)) { - throw new Error( - `stage-native-deps: source missing at ${spec.from}. Run \`npm install\` ` + - `at the workspace root first.` - ) - } - rmrf(spec.to) - ensureDir(spec.to) - - const files = walk(spec.from) - let copied = 0 - for (const abs of files) { - const rel = path.relative(spec.from, abs) - const included = spec.include.some(g => matchGlob(rel, g)) - if (!included) continue - const dest = path.join(spec.to, rel) - ensureDir(path.dirname(dest)) - fs.copyFileSync(abs, dest) - // node-pty's darwin spawn-helper and the Windows helper binaries - // (OpenConsole.exe, winpty-agent.exe) are invoked via posix_spawn / - // CreateProcess at runtime, so they must remain executable in the - // staged tree. fs.copyFileSync preserves source mode on POSIX, but we - // re-assert +x defensively for the darwin spawn-helper (no extension - // means a stripped mode would be silently broken at runtime). - if (path.basename(rel) === 'spawn-helper' && process.platform !== 'win32') { - try { fs.chmodSync(dest, 0o755) } catch { /* best-effort */ } - } - copied += 1 - } - console.log(`[stage-native-deps] ${path.relative(APP_ROOT, spec.to)}: ${copied} files`) -} - -// Resolve a package's directory by name, searching the repo-root node_modules -// first (where workspace dedup hoists everything) and then the requiring -// package's own node_modules for any non-hoisted nested copy. -// -// We deliberately do NOT use require.resolve(`${name}/package.json`): packages -// with an "exports" map that doesn't list "./package.json" (e.g. simple-git -// 3.x) make that subpath unresolvable under Node's exports enforcement -// (ERR_PACKAGE_PATH_NOT_EXPORTED), which fails on CI even though it happened to -// work locally. Instead resolve the package's main entry (exports-aware) and -// walk up to the directory whose package.json's "name" matches. -function resolvePkgDir(name, fromDir) { - const searchPaths = [fromDir, REPO_ROOT, path.join(REPO_ROOT, 'node_modules')] - let entry - try { - entry = require.resolve(name, { paths: searchPaths }) - } catch { - return null - } - // Walk up from the resolved entry file to the package root: the first - // ancestor dir whose package.json declares this package's name. - let dir = path.dirname(entry) - while (true) { - const pjPath = path.join(dir, 'package.json') - try { - const pj = JSON.parse(fs.readFileSync(pjPath, 'utf8')) - if (pj.name === name) { - return dir - } - } catch { - // no package.json here (or unreadable) — keep walking up - } - const parent = path.dirname(dir) - if (parent === dir) { - return null - } - dir = parent - } -} - -// Walk dependencies + optionalDependencies from each root package and return -// the set of resolved package directories in the runtime closure. Keyed by -// package name so a dep reached via two paths is staged once. -function resolveJsClosure(roots) { - const closure = new Map() // name -> absolute package dir - const stack = roots.map(name => ({ name, fromDir: REPO_ROOT })) - while (stack.length) { - const { name, fromDir } = stack.pop() - if (closure.has(name)) continue - const dir = resolvePkgDir(name, fromDir) - if (!dir) { - throw new Error( - `stage-native-deps: could not resolve '${name}' for the simple-git ` + - `closure. Run \`npm install\` at the workspace root first.` - ) - } - closure.set(name, dir) - let pj - try { - pj = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) - } catch { - continue - } - const deps = { ...(pj.dependencies || {}), ...(pj.optionalDependencies || {}) } - for (const depName of Object.keys(deps)) { - stack.push({ name: depName, fromDir: dir }) - } - } - return closure -} - -// Stage the resolved JS dependency closure into build/native-deps/vendor/node_modules/ -// so the packaged app (and the nix output) can require() it from -// process.resourcesPath when the hoisted-root require() isn't reachable. Each -// package is copied whole (minus node_modules/ — the closure is flattened so -// every dep already has its own top-level entry) into a real node_modules -// layout, which keeps the deps' own internal require()s working unchanged. -function stageJsClosure(roots) { - const closure = resolveJsClosure(roots) - rmrf(JS_DEP_STAGE_ROOT) - ensureDir(JS_DEP_STAGE_ROOT) - let staged = 0 - for (const [name, fromDir] of closure) { - const dest = path.join(JS_DEP_STAGE_ROOT, name) - ensureDir(path.dirname(dest)) - // Copy the package directory but skip any nested node_modules/ — the - // closure is flattened, so nested copies would just bloat the bundle. - fs.cpSync(fromDir, dest, { - recursive: true, - filter: src => path.basename(src) !== 'node_modules' - }) - staged += 1 - } - console.log( - `[stage-native-deps] vendor/node_modules/: ${staged} package(s) ` + - `(${[...closure.keys()].sort().join(', ')})` - ) -} - -function main() { - rmrf(STAGE_ROOT) - ensureDir(STAGE_ROOT) - for (const spec of NATIVE_DEPS) { - stageOne(spec) - } - stageJsClosure(JS_DEP_ROOTS) -} - -main() diff --git a/apps/desktop/scripts/stage-native-deps.mjs b/apps/desktop/scripts/stage-native-deps.mjs new file mode 100644 index 000000000000..8966fece6cb4 --- /dev/null +++ b/apps/desktop/scripts/stage-native-deps.mjs @@ -0,0 +1,325 @@ +#!/usr/bin/env node +// stage-native-deps.mjs — stages node-pty's native runtime dependencies +// +// Usage: +// node scripts/stage-native-deps.mjs # host platform/arch +// node scripts/stage-native-deps.mjs win32 arm64 # explicit target +// +// Also exported as `stageNodePty({ platform, arch })` for use from +// before-pack.mjs, where electron-builder gives you the real per-target +// platform/arch during multi-arch builds. + +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { dirname, resolve, join } from 'node:path' +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync +} from 'node:fs' +import { spawnSync } from 'node:child_process' +import { isMain } from './utils.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const projectRoot = resolve(here, '..') +const require = createRequire(import.meta.url) + +/** + * Locate node-pty's package root via real module resolution, so this + * works whether it's hoisted to a workspace root or local to this app. + */ +function resolveNodePtyRoot() { + const pkgJsonPath = require.resolve('node-pty/package.json', { + paths: [projectRoot] + }) + return dirname(pkgJsonPath) +} + +function copyGlobByExt(srcDir, destDir, extensions) { + if (!existsSync(srcDir)) return + mkdirSync(destDir, { recursive: true }) + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + copyGlobByExt(join(srcDir, entry.name), join(destDir, entry.name), extensions) + continue + } + if (extensions.some((ext) => entry.name.endsWith(ext))) { + mkdirSync(destDir, { recursive: true }) + cpSync(join(srcDir, entry.name), join(destDir, entry.name)) + } + } +} + +/** + * Copies the locally-compiled build/Release output (used when no prebuild + * was available and node-pty was built from source for the host machine). + * + * Filters by name/pattern rather than extension only: macOS builds a + * separate `spawn-helper` executable (no file extension) that + * lib/unixTerminal.js requires at a fixed relative path. Filtering this + * directory by ['.node'] silently drops it — the package then looks + * fine, ships fine, and crashes the first time a terminal is spawned. + * Directories are copied wholesale to also cover any nested native + * payload (e.g. a conpty/ subfolder some build layouts produce). + */ +function copyBuildRelease(srcDir, destDir) { + if (!existsSync(srcDir)) return + mkdirSync(destDir, { recursive: true }) + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + cpSync(join(srcDir, entry.name), join(destDir, entry.name), { recursive: true }) + continue + } + if (entry.name === 'spawn-helper' || /\.(node|dll|exe)$/.test(entry.name)) { + cpSync(join(srcDir, entry.name), join(destDir, entry.name)) + } + } +} + +// ─── binary classification ─────────────────────────────────────────── +// +// .node files are shared libraries in the target platform's native binary +// format. By reading the first few bytes (magic) we can determine which +// platform a given .node was compiled for, without shelling out to `file`. +// +// ELF (\x7fELF) → linux +// Mach-O 32-bit BE (feedface) → darwin +// Mach-O 64-bit BE (feedfacf) → darwin +// Mach-O 32-bit LE (cefaedfe — CIGAM) → darwin +// Mach-O 64-bit LE (cffaedfe — CIGAM_64) → darwin +// Fat/Universal BE (cafebabe) → darwin +// Fat/Universal LE (bebafeca — FAT_CIGAM) → darwin +// PE (MZ DOS header) → win32 +// +// Mach-O and Fat binaries are stored on disk in the host's native byte +// order. On x64/arm64 Darwin (every Apple Silicon + every Intel Mac that +// ships node-pty prebuilds) that is little-endian, so the on-disk magic is +// the CIGAM byte-swapped form, NOT the big-endian MH_MAGIC form. Checking +// only the BE constants misclassifies every real Darwin prebuild as unknown. +// +// Exported for unit testing. + +/** + * Classify a native binary's target platform from its magic bytes. + * Returns `'linux'`, `'darwin'`, `'win32'`, or `null` if unrecognized + * or the file cannot be read. + */ +export function classifyNativeBinary(filePath) { + let buf + try { + buf = readFileSync(filePath, { start: 0, end: 63 }) // first 64 bytes + } catch { + return null + } + if (buf.length < 4) return null + + // ELF: \x7f E L F + if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) { + return 'linux' + } + // Mach-O 32-bit (big-endian / MH_MAGIC): feedface + if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xce) { + return 'darwin' + } + // Mach-O 64-bit (big-endian / MH_MAGIC_64): feedfacf + if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xcf) { + return 'darwin' + } + // Mach-O 32-bit (little-endian / MH_CIGAM): cefaedfe + if (buf[0] === 0xce && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) { + return 'darwin' + } + // Mach-O 64-bit (little-endian / MH_CIGAM_64): cffaedfe + if (buf[0] === 0xcf && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) { + return 'darwin' + } + // Fat/Universal binary (big-endian / FAT_MAGIC): cafebabe + if (buf[0] === 0xca && buf[1] === 0xfe && buf[2] === 0xba && buf[3] === 0xbe) { + return 'darwin' + } + // Fat/Universal binary (little-endian / FAT_CIGAM): bebafeca + if (buf[0] === 0xbe && buf[1] === 0xba && buf[2] === 0xfe && buf[3] === 0xca) { + return 'darwin' + } + // PE: MZ DOS header + if (buf[0] === 0x4d && buf[1] === 0x5a) { + return 'win32' + } + return null +} + +/** + * Scan the staged destination tree for .node files and verify each one's + * binary platform matches the requested target. Throws on any mismatch. + * + * This is the fail-closed safety net: even if a prebuild or build/Release + * somehow slipped through with the wrong platform, this catches it before + * the package ships a broken native binary to users. + */ +function validateStagedBinaries(destRoot, targetPlatform) { + const mismatches = [] + function scan(dir, relPrefix) { + if (!existsSync(dir)) return + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + scan(join(dir, entry.name), `${relPrefix}${entry.name}/`) + continue + } + if (!entry.name.endsWith('.node')) continue + const fullPath = join(dir, entry.name) + const classified = classifyNativeBinary(fullPath) + if (classified !== targetPlatform) { + mismatches.push({ file: `${relPrefix}${entry.name}`, classified, expected: targetPlatform }) + } + } + } + scan(join(destRoot, 'prebuilds'), 'prebuilds/') + scan(join(destRoot, 'build', 'Release'), 'build/Release/') + if (mismatches.length > 0) { + throw new Error( + `[stage-native-deps] native binary platform mismatch (target=${targetPlatform}):\n` + + mismatches + .map((m) => ` ${m.file}: expected ${m.expected}, got ${m.classified ?? 'unknown'}`) + .join('\n') + + `\nRefusing to stage a binary compiled for the wrong platform.` + ) + } +} + +/** + * Stage node-pty's native runtime dependencies into `destRoot`. + * + * Exported separately from `stageNodePty` so tests can supply a fake + * node-pty source tree without going through real module resolution. + * + * Strategy (fail-closed): + * + * 1. Copy the matching prebuild (`prebuilds/<platform>-<arch>/`) if present. + * 2. Copy `build/Release/` **only when the target matches the host** — + * build/Release contains a binary compiled for the host's platform/arch, + * so staging it for a different target ships a broken app. + * 3. If no native binary was staged: + * - Same platform as host, different arch → run `electron-rebuild --arch`. + * - Different platform from host → throw (cannot cross-compile native + * modules; build on the target platform or provide a prebuild). + * 4. Validate every staged `.node` file's binary platform matches the target. + */ +export function stageNodePtyInto(srcRoot, destRoot, { platform = process.platform, arch = process.arch } = {}) { + const hostMatch = platform === process.platform && arch === process.arch + + rmSync(destRoot, { recursive: true, force: true }) + mkdirSync(destRoot, { recursive: true }) + + // package.json — needed so `require('node-pty')` resolves the package + // (reads "main") rather than treating it as a directory with no entry. + cpSync(join(srcRoot, 'package.json'), join(destRoot, 'package.json')) + + // lib/**/*.js — the JS surface node-pty's `main` points into. + copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js']) + + // prebuilds/<platform>-<arch>/* — the prebuild-install payload for the + // *target* we're packaging, not necessarily the host running this script. + // Explicit extensions only, to skip the ~25MB of Windows .pdb symbols + // prebuild-install bundles alongside the .node/.dll. + const prebuildDir = join(srcRoot, 'prebuilds', `${platform}-${arch}`) + if (existsSync(prebuildDir)) { + const destPrebuild = join(destRoot, 'prebuilds', `${platform}-${arch}`) + mkdirSync(destPrebuild, { recursive: true }) + for (const entry of readdirSync(prebuildDir, { withFileTypes: true })) { + if (entry.name === 'conpty' && entry.isDirectory()) { + cpSync(join(prebuildDir, 'conpty'), join(destPrebuild, 'conpty'), { recursive: true }) + continue + } + if (entry.isFile() && /\.(node|dll|exe)$/.test(entry.name)) { + cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name)) + continue + } + if (entry.name === 'spawn-helper') { + cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name)) + chmodSync(join(destPrebuild, entry.name), 0o775) + } + } + } + + // build/Release/* — present when node-pty was compiled locally + // (e.g. no prebuild available for this Electron ABI/platform combo). + // Only stage this when the target matches the host, because + // build/Release contains a binary compiled for the *host's* platform + // and architecture. Staging a host binary for a different target (e.g. + // a macOS Mach-O .node staged for a linux-arm64 target) ships a broken + // app that crashes the first time a terminal is spawned. + if (hostMatch) { + const buildReleaseDir = join(srcRoot, 'build/Release') + copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release')) + } + + // Check whether a native binary for this target was staged. + const stagedDirs = [ + join(destRoot, 'prebuilds', `${platform}-${arch}`), + join(destRoot, 'build/Release') + ] + const hasNativeBinary = stagedDirs.some((dir) => { + if (!existsSync(dir)) return false + return readdirSync(dir, { recursive: true }).some((name) => String(name).endsWith('.node')) + }) + + if (!hasNativeBinary) { + if (platform !== process.platform) { + throw new Error( + `[stage-native-deps] no prebuilt binary for ${platform}-${arch} and ` + + `cannot cross-compile native modules from ${process.platform}-${process.arch}. ` + + `Build on the target platform or provide a prebuild.` + ) + } + // Same platform, possibly different arch — rebuild from source with + // the target architecture so electron-rebuild produces the correct + // binary rather than defaulting to the host's arch. + console.log( + `[stage-native-deps] no native binary for ${platform}-${arch}; ` + + `running electron-rebuild (target arch: ${arch})...` + ) + const rebuildArgs = [ + '../../node_modules/.bin/electron-rebuild', + '-f', + '-w', + 'node-pty', + '--arch', + arch + ] + const result = spawnSync(process.execPath, rebuildArgs, { + cwd: projectRoot, + stdio: 'inherit' + }) + if (result.status !== 0) { + throw new Error( + `electron-rebuild failed for ${platform}-${arch} (exit ${result.status}). ` + + `Cannot stage node-pty without a native binary.` + ) + } + // Re-copy build/Release after electron-rebuild populated it. + const buildReleaseDir = join(srcRoot, 'build/Release') + copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release')) + } + + // Validate every staged .node binary matches the target platform. + validateStagedBinaries(destRoot, platform) + + console.log(`[stage-native-deps] staged node-pty (${platform}-${arch}) -> ${destRoot}`) + return destRoot +} + +export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) { + const srcRoot = resolveNodePtyRoot() + const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty') + return stageNodePtyInto(srcRoot, destRoot, { platform, arch }) +} + +// Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch] +if (isMain(import.meta.url)) { + const [platform, arch] = process.argv.slice(2) + stageNodePty({ platform, arch }) +} diff --git a/apps/desktop/scripts/stage-native-deps.test.mjs b/apps/desktop/scripts/stage-native-deps.test.mjs new file mode 100644 index 000000000000..65e1bb5d3022 --- /dev/null +++ b/apps/desktop/scripts/stage-native-deps.test.mjs @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict' +import fs, { existsSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test } from 'vitest' + +import { + stageNodePtyInto, + classifyNativeBinary +} from '../scripts/stage-native-deps.mjs' + +const { join } = path + +// ─── fixtures ────────────────────────────────────────────────────── +// +// Create minimal fake .node files with correct magic bytes so the +// binary classifier and the staging validator exercise real code paths +// without needing actual native modules. + +/** Write a fake .node file with the given platform's magic bytes. */ +function makeFakeNode(filePath, platform) { + const headers = { + linux: Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00, 0x00, 0x00]), // ELF + // On x64/arm64 Darwin, Mach-O binaries are stored little-endian on disk + // (MH_CIGAM_64 = cffaedfe). This is the form node-pty's prebuilds ship in. + darwin: Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00, 0x00, 0x00]), // Mach-O 64-bit LE (CIGAM_64) + win32: Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // MZ (PE) + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, headers[platform] ?? headers.linux) +} + +/** Create a minimal fake node-pty source tree in a temp dir. */ +function makeFakeNodePty(srcRoot, { prebuildPlatform, prebuildArch } = {}) { + fs.mkdirSync(srcRoot, { recursive: true }) + fs.writeFileSync(join(srcRoot, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'lib/index.js' })) + fs.mkdirSync(join(srcRoot, 'lib'), { recursive: true }) + fs.writeFileSync(join(srcRoot, 'lib', 'index.js'), 'module.exports = {};') + + if (prebuildPlatform && prebuildArch) { + const prebuildDir = join(srcRoot, 'prebuilds', `${prebuildPlatform}-${prebuildArch}`) + makeFakeNode(join(prebuildDir, 'pty.node'), prebuildPlatform) + } +} + +// ─── classifyNativeBinary tests ───────────────────────────────────── + +test('classifyNativeBinary detects ELF as linux', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'linux') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Mach-O 64-bit BE as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xcf, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Mach-O 64-bit LE (CIGAM_64) as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Mach-O 32-bit BE as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xce, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Mach-O 32-bit LE (CIGAM) as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xce, 0xfa, 0xed, 0xfe, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Fat/Universal BE (cafebabe) as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Fat/Universal LE (bebafeca / FAT_CIGAM) as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xbe, 0xba, 0xfe, 0xca, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects PE (MZ) as win32', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'win32') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary returns null for unrecognized magic', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), null) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary returns null for a missing file', () => { + assert.equal(classifyNativeBinary('/nonexistent/path/to/thing.node'), null) +}) + +// ─── cross-target regression tests ────────────────────────────────── +// +// The core bug: stageNodePty receives { platform, arch } from +// electron-builder but unconditionally copies host build/Release, staging +// a host binary for a foreign target. These tests prove the fix: +// +// 1. A host build/Release must NOT be staged for a foreign platform. +// 2. A matching prebuild IS staged for a foreign target. +// 3. A foreign target with no prebuild throws (fail closed). +// 4. A host build/Release IS staged for a matching target. +// 5. Validation rejects a binary whose magic bytes don't match the target. + +test('cross-target: host build/Release is NOT staged for a foreign platform', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const srcRoot = join(tmp, 'node-pty') + const destRoot = join(tmp, 'dest') + + // Create a node-pty tree with ONLY a host build/Release (no prebuild). + makeFakeNodePty(srcRoot) + const buildReleaseDir = join(srcRoot, 'build', 'Release') + makeFakeNode(join(buildReleaseDir, 'pty.node'), process.platform) + + // Request a foreign platform (different from the host). + const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux' + + assert.throws( + () => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }), + /cannot cross-compile/i + ) + + // build/Release must NOT have been copied to the dest tree. + assert.equal( + existsSync(join(destRoot, 'build', 'Release', 'pty.node')), + false, + 'host build/Release .node must not be staged for a foreign target' + ) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('cross-target: matching prebuild IS staged for a foreign target', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const srcRoot = join(tmp, 'node-pty') + const destRoot = join(tmp, 'dest') + + // Host is (say) darwin. Request linux-x64, which has a prebuild. + const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux' + makeFakeNodePty(srcRoot, { prebuildPlatform: foreignPlatform, prebuildArch: 'x64' }) + + // Also create a host build/Release that should NOT be staged. + makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform) + + stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }) + + // The foreign prebuild must be staged. + const stagedPrebuild = join(destRoot, 'prebuilds', `${foreignPlatform}-x64`, 'pty.node') + assert.equal(existsSync(stagedPrebuild), true, 'foreign prebuild must be staged') + + // The host build/Release must NOT be staged. + assert.equal( + existsSync(join(destRoot, 'build', 'Release', 'pty.node')), + false, + 'host build/Release must not be staged for a foreign target' + ) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('cross-target: foreign target with no prebuild throws (fail closed)', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const srcRoot = join(tmp, 'node-pty') + const destRoot = join(tmp, 'dest') + + // Create a tree with a host build/Release but no foreign prebuild. + makeFakeNodePty(srcRoot) + makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform) + + const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux' + + assert.throws( + () => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }), + /cannot cross-compile/i + ) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('host-target: host build/Release IS staged for a matching target', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const srcRoot = join(tmp, 'node-pty') + const destRoot = join(tmp, 'dest') + + makeFakeNodePty(srcRoot) + makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform) + + stageNodePtyInto(srcRoot, destRoot, { platform: process.platform, arch: process.arch }) + + assert.equal( + existsSync(join(destRoot, 'build', 'Release', 'pty.node')), + true, + 'host build/Release must be staged for a matching target' + ) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('validation rejects a staged binary with the wrong platform magic', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const srcRoot = join(tmp, 'node-pty') + const destRoot = join(tmp, 'dest') + + // Create a prebuild dir that claims to be linux-x64 but contains + // a darwin (Mach-O) binary. This simulates the original bug where + // a host binary ends up in a foreign target's prebuild slot. + makeFakeNodePty(srcRoot, { prebuildPlatform: 'linux', prebuildArch: 'x64' }) + // Overwrite the prebuild .node with the WRONG platform magic. + makeFakeNode(join(srcRoot, 'prebuilds', 'linux-x64', 'pty.node'), 'darwin') + + assert.throws( + () => stageNodePtyInto(srcRoot, destRoot, { platform: 'linux', arch: 'x64' }), + /platform mismatch/i + ) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/scripts/test-desktop.mjs b/apps/desktop/scripts/test-desktop.mjs index fdff1523f8f5..ec94ddc5e7fc 100644 --- a/apps/desktop/scripts/test-desktop.mjs +++ b/apps/desktop/scripts/test-desktop.mjs @@ -5,10 +5,11 @@ import { spawn, spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { listPackage } from '@electron/asar' -const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8')) +import PACKAGE_JSON from '../package.json' with { type: 'json' } + const MODE = process.argv[2] || 'help' const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64' +const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release') const PLATFORM = process.platform @@ -41,14 +42,14 @@ const APP = (() => { const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked') return { appPath: unpacked, - binary: path.join(unpacked, 'hermes'), + binary: path.join(unpacked, 'Hermes'), resourcesPath: path.join(unpacked, 'resources'), asarPath: path.join(unpacked, 'resources', 'app.asar'), unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html') } })() -// Default HERMES_HOME for non-sandboxed runs -- matches main.cjs's +// Default HERMES_HOME for non-sandboxed runs -- matches main.ts's // resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere // it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own // HERMES_HOME and never touches this. @@ -83,17 +84,23 @@ function exists(target) { return fs.existsSync(target) } -// Match nodepty native binding location to what main.cjs's resolver fallback -// expects (apps/desktop/electron/main.cjs, packaged-build branch). Upstream -// node-pty 1.x is N-API based and ships per-arch prebuilts under -// prebuilds/<platform>-<arch>/ instead of build/Release/. We check the -// per-arch dir since that's what stage-native-deps actually copies. +// Match node-pty native binding location to what the bundled electron-main.cjs +// resolves at runtime. stage-native-deps.mjs stages node-pty into +// dist/node_modules/node-pty, and dist/** is asarUnpacked (see package.json +// build.asarUnpack), so in a packaged build it lands under +// resources/app.asar.unpacked/dist/node_modules/node-pty — reachable by a bare +// require('node-pty') from the bundle. Upstream node-pty 1.x is N-API based and +// ships per-arch prebuilts under prebuilds/<platform>-<arch>/; nix/local builds +// instead compile from source into build/Release/. The stage script copies +// whichever is present, so we accept either as the native payload. function expectedNativeDepPaths() { - const root = path.join(APP.resourcesPath, 'native-deps', 'node-pty') + const root = path.join(APP.resourcesPath, 'app.asar.unpacked', 'dist', 'node_modules', 'node-pty') const prebuildsDir = path.join(root, 'prebuilds', `${PLATFORM}-${ARCH}`) + const buildReleaseDir = path.join(root, 'build', 'Release') return { packageJson: path.join(root, 'package.json'), prebuildsDir, + buildReleaseDir, libIndex: path.join(root, 'lib', 'index.js') } } @@ -101,10 +108,9 @@ function expectedNativeDepPaths() { function ensurePlatformBuilds() { if (PLATFORM === 'darwin') return if (PLATFORM === 'win32') return + if (PLATFORM === 'linux') return die( - `Desktop bundle validation is only wired for darwin / win32 today; platform=${PLATFORM} ` + - `is not yet supported. The thin-installer story for Linux ships in Phase 2 alongside ` + - `install.sh's stage protocol.` + `Desktop bundle validation is only wired for darwin / win32 / linux; platform=${PLATFORM} is not supported.` ) } @@ -279,8 +285,8 @@ function launchFresh() { // - The Hermes Agent Python payload is NOT shipped (it's fetched at first // launch via install.ps1's stage protocol). // - install-stamp.json IS shipped in resources/ with a valid commit + branch. -// - native-deps/@homebridge/node-pty-prebuilt-multiarch/ IS shipped with -// the package.json + lib/ + at least one .node binary (the renderer's +// - node-pty IS shipped inside app.asar.unpacked/dist/node_modules/node-pty +// with package.json + lib/ + at least one .node binary (the renderer's // integrated terminal needs this; see Phase 1F.6). // - The renderer's dist/index.html is reachable (either unpacked or // inside app.asar). @@ -320,24 +326,35 @@ function validateBundle() { // Positive assertion: node-pty native deps shipped const native = expectedNativeDepPaths() if (!exists(native.packageJson)) { - die(`Missing node-pty package.json in resources/native-deps: ${native.packageJson}`) + die(`Missing node-pty package.json in app.asar.unpacked: ${native.packageJson}`) } if (!exists(native.libIndex)) { - die(`Missing node-pty lib/index.js in resources/native-deps: ${native.libIndex}`) + die(`Missing node-pty lib/index.js in app.asar.unpacked: ${native.libIndex}`) } - if (!exists(native.prebuildsDir)) { - die(`Missing node-pty prebuilds dir for ${PLATFORM}-${ARCH}: ${native.prebuildsDir}`) + // The native binary lands in prebuilds/<platform>-<arch>/ (downloaded prebuild) + // OR build/Release/ (compiled from source). stage-native-deps.mjs copies + // whichever is present, so accept either. + const nativeBinaryDirs = [native.prebuildsDir, native.buildReleaseDir].filter(exists) + if (nativeBinaryDirs.length === 0) { + die( + `Missing node-pty native binary dir for ${PLATFORM}-${ARCH}: neither ` + + `${native.prebuildsDir} nor ${native.buildReleaseDir} exists` + ) } - const nodeBinaries = fs.readdirSync(native.prebuildsDir).filter(name => name.endsWith('.node')) + const nodeBinaries = nativeBinaryDirs.flatMap(dir => + fs.readdirSync(dir).filter(name => name.endsWith('.node')) + ) if (nodeBinaries.length === 0) { - die(`No .node native binaries found in: ${native.prebuildsDir}`) + die(`No .node native binaries found in: ${nativeBinaryDirs.join(', ')}`) } // Darwin requires a runtime-execed spawn-helper alongside pty.node; missing // it manifests as "ENOENT: spawn-helper" on first pty.spawn() call. if (PLATFORM === 'darwin') { - const spawnHelper = path.join(native.prebuildsDir, 'spawn-helper') - if (!exists(spawnHelper)) { - die(`Missing node-pty spawn-helper (required on darwin): ${spawnHelper}`) + const spawnHelper = nativeBinaryDirs + .map(dir => path.join(dir, 'spawn-helper')) + .find(exists) + if (!spawnHelper) { + die(`Missing node-pty spawn-helper (required on darwin) in: ${nativeBinaryDirs.join(', ')}`) } } diff --git a/apps/desktop/scripts/utils.mjs b/apps/desktop/scripts/utils.mjs new file mode 100644 index 000000000000..7010213ec3cd --- /dev/null +++ b/apps/desktop/scripts/utils.mjs @@ -0,0 +1,8 @@ + +import { pathToFileURL } from 'node:url'; + +// returns true if the passsed file is being invoked from node, +// not imported. +export function isMain(importMetaUrl) { + return importMetaUrl === pathToFileURL(process.argv[1]).href; +} \ No newline at end of file diff --git a/apps/desktop/scripts/write-build-stamp.cjs b/apps/desktop/scripts/write-build-stamp.mjs similarity index 86% rename from apps/desktop/scripts/write-build-stamp.cjs rename to apps/desktop/scripts/write-build-stamp.mjs index 72b978c5f9a3..005db35d1772 100644 --- a/apps/desktop/scripts/write-build-stamp.cjs +++ b/apps/desktop/scripts/write-build-stamp.mjs @@ -1,10 +1,8 @@ -"use strict" - /** * Writes apps/desktop/build/install-stamp.json with the git ref the desktop * .exe should pin to at first-launch bootstrap time. This file ships inside * the packaged app via electron-builder's extraResources entry and is read - * by electron/main.cjs to drive the install.ps1 stage bootstrap flow. + * by electron/main.ts to drive the install.ps1 stage bootstrap flow. * * Schema (subject to bump via STAMP_SCHEMA_VERSION): * { @@ -26,16 +24,16 @@ * bootstrap without a stamp. */ -const fs = require("fs") -const path = require("path") -const { execSync } = require("child_process") +import { mkdirSync, writeFileSync } from "fs" +import { resolve, join, relative } from "path" +import { execSync } from "child_process" const STAMP_SCHEMA_VERSION = 1 -const DESKTOP_ROOT = path.resolve(__dirname, "..") -const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..") -const OUT_DIR = path.join(DESKTOP_ROOT, "build") -const OUT_FILE = path.join(OUT_DIR, "install-stamp.json") +const DESKTOP_ROOT = resolve(import.meta.dirname, "..") +const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..") +const OUT_DIR = join(DESKTOP_ROOT, "build") +const OUT_FILE = join(OUT_DIR, "install-stamp.json") function tryExec(cmd, opts) { try { @@ -111,11 +109,11 @@ function main() { source: stamp.source } - fs.mkdirSync(OUT_DIR, { recursive: true }) - fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8") + mkdirSync(OUT_DIR, { recursive: true }) + writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8") console.log( "[write-build-stamp] wrote " + - path.relative(REPO_ROOT, OUT_FILE) + + relative(REPO_ROOT, OUT_FILE) + " -> " + stamp.commit.slice(0, 12) + (stamp.branch ? " (" + stamp.branch + ")" : "") + diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 5ae0cd424747..70a6a0338ca3 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -216,7 +216,10 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . const titles = [...new Set(artifacts.map(artifact => artifact.sessionTitle).filter(Boolean))].slice(0, 2) - const hints = [...extensions.map(ext => t.common.tryHint(`.${ext}`)), ...titles.map(title => t.common.tryHint(title))] + const hints = [ + ...extensions.map(ext => t.common.tryHint(`.${ext}`)), + ...titles.map(title => t.common.tryHint(title)) + ] return hints.length > 0 ? hints : undefined }, [artifacts, t]) diff --git a/apps/desktop/src/app/chat/chat-drop-overlay.tsx b/apps/desktop/src/app/chat/chat-drop-overlay.tsx index ff01687aaccb..37d8172eb1e5 100644 --- a/apps/desktop/src/app/chat/chat-drop-overlay.tsx +++ b/apps/desktop/src/app/chat/chat-drop-overlay.tsx @@ -1,48 +1,48 @@ import { useRef } from 'react' import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone' -import { Codicon } from '@/components/ui/codicon' +import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' -const ICONS: Record<'files' | 'session', string> = { - files: 'cloud-upload', - session: 'comment-discussion' -} - /** * Full-bleed affordance shown while files or a session are dragged over the chat * area. Always `pointer-events-none` so the drop lands on the real element * underneath and the drop-zone handler claims it — the overlay is purely visual. - * Copy adapts to whatever is being dragged; the last kind is held through the - * fade-out so the label doesn't blank. + * The label names the outcome (attach files / link this chat); the last kind is + * held through the fade-out so it doesn't blank. */ export function ChatDropOverlay({ kind }: { kind: DragKind }) { const { t } = useI18n() - const lastKind = useRef<'files' | 'session'>('files') + const lastKind = useRef<DragKind>(kind) if (kind) { lastKind.current = kind } - const resolvedKind = kind ?? lastKind.current - const icon = ICONS[resolvedKind] - const label = resolvedKind === 'files' ? t.composer.dropFiles : t.composer.dropSession + const shown = kind ?? lastKind.current return ( <div aria-hidden className={cn( - 'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out', + 'pointer-events-none absolute inset-0 z-40 flex items-center justify-center transition-opacity duration-150 ease-out', kind ? 'opacity-100' : 'opacity-0' )} data-slot="chat-drop-overlay" > - <div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" /> - <div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer"> - <Codicon className="text-(--ui-accent)" name={icon} size="1rem" /> - {label} - </div> + <div + className={cn( + DROP_SHEET_CLASS, + DROP_SHEET_BLUR_CLASS, + 'absolute inset-2 border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)]' + )} + /> + {shown && ( + <span className="relative text-[11px] font-medium uppercase tracking-wide text-foreground"> + {shown === 'session' ? t.composer.dropSession : t.composer.dropFiles} + </span> + )} </div> ) } diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts new file mode 100644 index 000000000000..100e14365852 --- /dev/null +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -0,0 +1,29 @@ +import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals' +import { closeWorkspaceTab } from '@/components/pane-shell/tree/store' +import { isFocusWithin } from '@/lib/keybinds/combo' +import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '@/store/preview' + +/** + * ⌘W — close the tab of the context you're in, by precedence: + * 1. a focused terminal → its active terminal tab, + * 2. an open preview → its active preview tab (unchanged from pre-tiling), + * 3. the MAIN zone → its active tab (a session tile stacked into the workspace). + * Returns false when nothing closes, so ⌘W is a no-op — it never closes the + * window (a bare workspace stays put). Shared by the keyboard path (Win/Linux) + * and the macOS menu-accelerator IPC. + */ +export function closeActiveTab(): boolean { + if (isFocusWithin('[data-terminal]')) { + closeActiveTerminal() + + return true + } + + if ($filePreviewTarget.get() || $previewTarget.get()) { + closeActiveRightRailTab() + + return true + } + + return closeWorkspaceTab() +} diff --git a/apps/desktop/src/app/chat/composer/attachments.test.tsx b/apps/desktop/src/app/chat/composer/attachments.test.tsx index 0ea85811315f..52e8f6bf98e8 100644 --- a/apps/desktop/src/app/chat/composer/attachments.test.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { I18nProvider } from '@/i18n/context' @@ -10,12 +10,17 @@ function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment { return { id, kind: 'file', label } } -function renderWithI18n(ui: React.ReactNode) { - return render( - <I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}> - {ui} - </I18nProvider> - ) +async function renderWithI18n(ui: React.ReactNode) { + let result: ReturnType<typeof render> + await act(async () => { + result = render( + <I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}> + {ui} + </I18nProvider> + ) + }) + + return result! } describe('AttachmentList', () => { @@ -23,23 +28,22 @@ describe('AttachmentList', () => { cleanup() }) - it('renders valid attachments', () => { + it('renders valid attachments', async () => { const attachments = [makeAttachment('a', 'doc.pdf'), makeAttachment('b', 'img.png')] - renderWithI18n(<AttachmentList attachments={attachments} />) + await renderWithI18n(<AttachmentList attachments={attachments} />) expect(screen.getByText('doc.pdf')).toBeDefined() expect(screen.getByText('img.png')).toBeDefined() }) - it('renders empty list without error', () => { - renderWithI18n(<AttachmentList attachments={[]} />) + it('renders empty list without error', async () => { + const { container } = await renderWithI18n(<AttachmentList attachments={[]} />) - const container = - screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]') + const attachmentList = container.querySelector('[data-slot="composer-attachments"]') - expect(container).toBeDefined() + expect(attachmentList).toBeDefined() }) - it('does not crash when attachments array contains undefined entries', () => { + it('does not crash when attachments array contains undefined entries', async () => { // Repro: session switch can leave stale/undefined entries in the // attachments array, causing a TypeError at attachment.refText. const attachments = [ @@ -48,21 +52,17 @@ describe('AttachmentList', () => { makeAttachment('b', 'also-good.png') ] - expect(() => { - renderWithI18n(<AttachmentList attachments={attachments} />) - }).not.toThrow() + await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy() // Only valid attachments should render expect(screen.getByText('good.pdf')).toBeDefined() expect(screen.getByText('also-good.png')).toBeDefined() }) - it('does not crash when attachments array contains null entries', () => { + it('does not crash when attachments array contains null entries', async () => { const attachments = [null as unknown as ComposerAttachment, makeAttachment('a', 'valid.txt')] - expect(() => { - renderWithI18n(<AttachmentList attachments={attachments} />) - }).not.toThrow() + await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy() expect(screen.getByText('valid.txt')).toBeDefined() }) diff --git a/apps/desktop/src/app/chat/composer/composer-utils.test.ts b/apps/desktop/src/app/chat/composer/composer-utils.test.ts index 9fc5f5b5730c..4df8463ba2de 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.test.ts @@ -1,7 +1,14 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core' import { describe, expect, it } from 'vitest' -import { pickPlaceholder, slashArgStage, slashChipKindForItem, slashCommandToken } from './composer-utils' +import { + isPendingDraftPersistCurrent, + type PendingDraftPersist, + pickPlaceholder, + slashArgStage, + slashChipKindForItem, + slashCommandToken +} from './composer-utils' const item = (group: string): Unstable_TriggerItem => ({ id: 'x', type: 'slash', label: 'x', metadata: { group } }) as unknown as Unstable_TriggerItem @@ -38,3 +45,36 @@ describe('pickPlaceholder', () => { expect(pool).toContain(pickPlaceholder(pool)) }) }) + +describe('isPendingDraftPersistCurrent (#54527 integrity guard)', () => { + it('accepts a write when the pending entry still matches what was captured', () => { + const entry: PendingDraftPersist = { scope: 'session-a', text: 'hello' } + + expect(isPendingDraftPersistCurrent(entry, entry)).toBe(true) + expect(isPendingDraftPersistCurrent({ scope: 'session-a', text: 'hello' }, entry)).toBe(true) + }) + + it('rejects when the pending slot was cleared (session swap / newer flush already committed)', () => { + const entry: PendingDraftPersist = { scope: 'session-a', text: 'hello' } + + expect(isPendingDraftPersistCurrent(null, entry)).toBe(false) + }) + + it('rejects when the pending slot now belongs to a different session (the #54527 misroute shape)', () => { + const captured: PendingDraftPersist = { scope: 'session-a', text: 'carefully composed prompt' } + const supersededBy: PendingDraftPersist = { scope: 'session-b', text: 'different draft' } + + expect(isPendingDraftPersistCurrent(supersededBy, captured)).toBe(false) + }) + + it('rejects when the pending slot was replaced by a newer keystroke in the same session', () => { + const captured: PendingDraftPersist = { scope: 'session-a', text: 'first draft' } + const supersededBy: PendingDraftPersist = { scope: 'session-a', text: 'first draft continued' } + + expect(isPendingDraftPersistCurrent(supersededBy, captured)).toBe(false) + }) + + it('rejects when nothing was ever captured', () => { + expect(isPendingDraftPersistCurrent(null, null)).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index ad7b63787fd0..7939be35b6b6 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -6,6 +6,12 @@ import { setSessionPickerOpen } from '@/store/session' export const COMPOSER_STACK_BREAKPOINT_PX = 320 +// Above the stack breakpoint but still cramped: the model pill sheds its label +// for its chevron icon (freeing ~120px) so the controls stop crowding the input +// before the whole row has to stack. Progressive collapse: full pill → icon +// pill → stacked. +export const COMPOSER_COMPACT_PILL_PX = 440 + // A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem // vertical padding). Anything taller means the text wrapped to a second line, // which is when the composer should expand to the stacked layout. @@ -58,3 +64,26 @@ export interface QueueEditState { } export const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) + +export interface PendingDraftPersist { + scope: string | null + text: string +} + +/** + * Defense-in-depth for #54527: the debounce timer and the `pagehide` flush + * both write a captured `{ scope, text }` pair some time after it was + * scheduled. Before either commits the write, this checks the pair is still + * the one currently on file — i.e. nothing cleared or replaced it in the + * meantime (a session swap, a newer keystroke). The scope-capture fix + * upstream (`draftScopeRef`) already makes every captured pair correct by + * construction; this guard exists so that if a future change reintroduces a + * stale/live-ref read at one of these call sites, the write is dropped + * instead of silently filing one session's text under another session's key. + */ +export function isPendingDraftPersistCurrent( + pending: PendingDraftPersist | null, + expected: PendingDraftPersist | null +): boolean { + return pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text +} diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx index 57c34ebde38c..e0c12803733c 100644 --- a/apps/desktop/src/app/chat/composer/context-menu.tsx +++ b/apps/desktop/src/app/chat/composer/context-menu.tsx @@ -18,6 +18,7 @@ import { useI18n } from '@/i18n' import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons' import { cn } from '@/lib/utils' +import { useComposerAttachmentProviders } from './contrib' import { GHOST_ICON_BTN } from './controls' import type { ChatBarState } from './types' @@ -39,6 +40,9 @@ export function ContextMenu({ // window (composer "+" anchor), so we promoted it to a real Dialog — // easier to grow with search / descriptions, and no positioning math. const [snippetsOpen, setSnippetsOpen] = useState(false) + // `composer.attachments` contributions — plugin/core-registered rows that + // extend this menu through the same registry as every other surface. + const attachmentProviders = useComposerAttachmentProviders() return ( <> @@ -90,6 +94,18 @@ export function ContextMenu({ {c.promptSnippets} </ContextMenuItem> + {attachmentProviders.length > 0 && <DropdownMenuSeparator />} + {attachmentProviders.map(provider => ( + <DropdownMenuItem + className="text-[length:var(--conversation-tool-font-size)] focus:bg-(--ui-bg-tertiary)" + key={provider.key} + onSelect={() => void provider.run({ insertText: onInsertText })} + > + <Codicon name={provider.icon ?? 'plug'} size="0.875rem" /> + <span>{provider.label}</span> + </DropdownMenuItem> + ))} + <DropdownMenuSeparator /> <div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80"> diff --git a/apps/desktop/src/app/chat/composer/contrib.test.ts b/apps/desktop/src/app/chat/composer/contrib.test.ts new file mode 100644 index 000000000000..d90229a8c896 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/contrib.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { registry } from '@/contrib/registry' + +import { COMPOSER_AREAS, type ComposerMiddleware, runComposerMiddleware } from './contrib' + +const disposers: Array<() => void> = [] + +function addMiddleware(id: string, handler: ComposerMiddleware['handler'], order?: number) { + disposers.push( + registry.register({ id, area: COMPOSER_AREAS.middleware, order, data: { handler } satisfies ComposerMiddleware }) + ) +} + +afterEach(() => { + disposers.splice(0).forEach(d => d()) +}) + +describe('runComposerMiddleware', () => { + it('passes the draft through untouched when nothing is registered', async () => { + const draft = { text: 'hello' } + + expect(await runComposerMiddleware(draft)).toBe(draft) + }) + + it('chains rewrites in registry order', async () => { + addMiddleware('b', d => ({ ...d, text: `${d.text}b` }), 20) + addMiddleware('a', d => ({ ...d, text: `${d.text}a` }), 10) + + expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'xab' }) + }) + + it('cancels the send when a handler returns null', async () => { + addMiddleware('gate', () => null) + addMiddleware('later', d => ({ ...d, text: 'never' }), 99) + + expect(await runComposerMiddleware({ text: 'x' })).toBeNull() + }) + + it('treats a throwing handler as pass-through', async () => { + addMiddleware('boom', () => { + throw new Error('broken plugin') + }) + addMiddleware('after', d => ({ ...d, text: `${d.text}!` }), 99) + + expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'x!' }) + }) + + it('supports async handlers', async () => { + addMiddleware('async', async d => ({ ...d, text: d.text.toUpperCase() })) + + expect(await runComposerMiddleware({ text: 'quiet' })).toEqual({ text: 'QUIET' }) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/contrib.ts b/apps/desktop/src/app/chat/composer/contrib.ts new file mode 100644 index 000000000000..893f5174c200 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/contrib.ts @@ -0,0 +1,94 @@ +/** + * Composer contribution surface — every seam of the composer is hook-into-able + * through the SAME registry schema as every other surface (statusbar, titlebar, + * panes, layouts): + * + * render areas (`render`): composer.top — banner strip above the input + * composer.bottom — row below the input grid + * composer.leading — inline after the "+" menu + * composer.actions — inline before the model pill + * + * data kinds (`data`): composer.middleware (ComposerMiddleware) + * composer.attachments (ComposerAttachmentProvider) + * + * Core keeps ownership of the transcript, input, and submit engine — these + * seams AUGMENT the composer, they never replace it. Middleware runs as an + * ordered async chain around the app's onSubmit: each handler may rewrite the + * draft, pass it through, or cancel the send by returning null. + */ + +import { useContributions } from '@/contrib/react/use-contributions' +import { registry } from '@/contrib/registry' +import type { ComposerAttachment } from '@/store/composer' + +export const COMPOSER_AREAS = { + top: 'composer.top', + bottom: 'composer.bottom', + leading: 'composer.leading', + actions: 'composer.actions', + middleware: 'composer.middleware', + attachments: 'composer.attachments' +} as const + +export interface ComposerDraft { + text: string + attachments?: ComposerAttachment[] +} + +/** Payload of a `composer.middleware` data contribution. */ +export interface ComposerMiddleware { + /** Rewrite (return a draft), pass through (same draft), or cancel (null). */ + handler: (draft: ComposerDraft) => ComposerDraft | null | Promise<ComposerDraft | null> +} + +export interface ComposerAttachmentContext { + insertText: (text: string) => void +} + +/** Payload of a `composer.attachments` data contribution — an entry in the + * composer's "+" attach menu. */ +export interface ComposerAttachmentProvider { + label: string + /** Codicon name for the menu row. Defaults to `plug`. */ + icon?: string + run: (ctx: ComposerAttachmentContext) => void | Promise<void> +} + +/** + * Run the ordered middleware chain over a draft. Contributions execute in + * registry order (`order`, then registration order); the first `null` wins + * and cancels the send. A throwing handler is treated as pass-through so a + * broken plugin can't eat messages. + */ +export async function runComposerMiddleware(draft: ComposerDraft): Promise<ComposerDraft | null> { + let current = draft + + for (const contribution of registry.getArea(COMPOSER_AREAS.middleware)) { + const middleware = contribution.data as ComposerMiddleware | undefined + + if (!middleware?.handler) { + continue + } + + try { + const next = await middleware.handler(current) + + if (next === null) { + return null + } + + current = next + } catch { + // Pass-through: a faulty middleware must never swallow the message. + } + } + + return current +} + +/** Attach-menu entries contributed by plugins/core, with stable render keys. */ +export function useComposerAttachmentProviders(): Array<ComposerAttachmentProvider & { key: string }> { + return useContributions(COMPOSER_AREAS.attachments) + .map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as ComposerAttachmentProvider) })) + .filter(p => Boolean(p.label && p.run)) +} diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index d3969b700200..238642f54e49 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -13,7 +13,9 @@ import type { InlineRefInput } from './inline-refs' import { RICH_INPUT_SLOT } from './rich-editor' -export type ComposerTarget = 'edit' | 'main' +/** Composer routing key. The main chat is `'main'`, the edit composer + * `'edit'`; scoped composers (session tiles) use `'tile:<id>'`. */ +export type ComposerTarget = 'edit' | 'main' | (string & {}) export type ComposerInsertMode = 'block' | 'inline' interface FocusDetail { @@ -76,6 +78,10 @@ export const markActiveComposer = (target: ComposerTarget) => { activeTarget = target } +/** The composer that last held focus — the target `'active'` resolves to. + * Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one. */ +export const getActiveComposer = (): ComposerTarget => activeTarget + export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') => dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) }) @@ -129,12 +135,14 @@ export const requestComposerSubmit = ( export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) => subscribe<SubmitDetail>(SUBMIT_EVENT, handler) -/** Toggle the active composer's voice conversation — the `composer.voice` - * hotkey (Ctrl+B) reaching into the composer that owns the voice state. */ -export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() }) +/** Toggle ONE composer's voice conversation — the `composer.voice` hotkey + * (Ctrl+B) reaches the composer that owns voice. Defaults to the active + * composer so N tiles don't all flip together. */ +export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active') => + dispatch<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, { target: resolve(target) }) -export const onComposerVoiceToggleRequest = (handler: () => void) => - subscribe<{ at: number }>(VOICE_TOGGLE_EVENT, () => handler()) +export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) => + subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target)) /** * Focus a composer input across React commit + browser focus restore. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts index a8e5c65888cd..60a5255eec67 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts @@ -1,8 +1,9 @@ import { type MutableRefObject, useCallback } from 'react' -import { clearComposerAttachments } from '@/store/composer' import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' +import { useComposerScope } from '../scope' + interface UseComposerBranchOptions { clearDraft: () => void cwd: null | string | undefined @@ -17,6 +18,8 @@ interface UseComposerBranchOptions { * projects store) is the only dependency; nothing about ChatBar's render. */ export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBranchOptions) { + const scope = useComposerScope() + // Hand a worktree off to the controller: open a fresh session anchored there, // carrying the composer draft as its first turn. Clearing here means the draft // travels to the new session instead of getting stashed under this one. @@ -24,7 +27,7 @@ export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBran (path: string) => { const text = draftRef.current clearDraft() - clearComposerAttachments() + scope.attachments.clear() requestStartWorkSession(path, text) }, [clearDraft, draftRef] diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 5f8bcf8e2330..b9fa789ad51d 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -2,10 +2,15 @@ import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react' import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' -import { $composerAttachments, type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer' +import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer' import { isBrowsingHistory } from '@/store/composer-input-history' -import { cloneAttachments, DRAFT_PERSIST_DEBOUNCE_MS, type QueueEditState } from '../composer-utils' +import { + cloneAttachments, + DRAFT_PERSIST_DEBOUNCE_MS, + isPendingDraftPersistCurrent, + type QueueEditState +} from '../composer-utils' import { type ComposerInsertMode, focusComposerInput, @@ -16,6 +21,7 @@ import { } from '../focus' import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs' import { composerPlainText, placeCaretEnd, renderComposerContents } from '../rich-editor' +import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' interface UseComposerDraftArgs { @@ -45,6 +51,8 @@ export function useComposerDraft({ }: UseComposerDraftArgs) { const aui = useAui() const composerRuntime = useComposerRuntime() + // Which composer this is on the focus bus + which attachment set it owns. + const { attachments: attachmentScope, target } = useComposerScope() // Coarse edges only — these flip rarely (empty↔non-empty, the `?` help sigil, // steerable-vs-slash), so typing within a line costs no render. @@ -77,6 +85,13 @@ export function useComposerDraft({ const draftPersistTimerRef = useRef<number | undefined>(undefined) const activeQueueSessionKeyRef = useRef(activeQueueSessionKey) activeQueueSessionKeyRef.current = activeQueueSessionKey + // Owned only by the swap effect below — unlike activeQueueSessionKeyRef this + // does NOT update on every render, so it always reflects the session whose + // text is actually loaded in the editor. Async work (debounce timers, + // pagehide flush) must persist against this, not the render-time ref, or a + // session switch mid-flight files one session's draft under another's key + // (#54527). + const draftScopeRef = useRef(activeQueueSessionKey) const sessionIdRef = useRef(sessionId) sessionIdRef.current = sessionId const queueEditStateRef = useRef<QueueEditState | null>(queueEditRef.current) @@ -86,8 +101,8 @@ export function useComposerDraft({ const focusInput = useCallback(() => { focusComposerInput(editorRef.current) - markActiveComposer('main') - }, []) + markActiveComposer(target) + }, [target]) const requestMainFocus = useCallback(() => { setFocusRequestId(id => id + 1) @@ -143,14 +158,14 @@ export function useComposerDraft({ return undefined } - const offFocus = onComposerFocusRequest(target => { - if (target === 'main') { + const offFocus = onComposerFocusRequest(requested => { + if (requested === target) { setFocusRequestId(id => id + 1) } }) - const offInsert = onComposerInsertRequest(({ mode, target, text }) => { - if (target === 'main') { + const offInsert = onComposerInsertRequest(({ mode, target: requested, text }) => { + if (requested === target) { appendExternalText(text, mode) } }) @@ -159,13 +174,13 @@ export function useComposerDraft({ offFocus() offInsert() } - }, [appendExternalText, inputDisabled]) + }, [appendExternalText, inputDisabled, target]) - const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) => + const stashAt = (scope: string | null, text = draftRef.current, attachments = attachmentScope.$attachments.get()) => stashSessionDraft(scope, text, attachments) const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { - $composerAttachments.set(cloneAttachments(attachments)) + attachmentScope.$attachments.set(cloneAttachments(attachments)) paintDraft(text, false) } @@ -222,10 +237,20 @@ export function useComposerDraft({ return } - const scope = activeQueueSessionKeyRef.current - pendingDraftPersistRef.current = { scope, text } + const scope = draftScopeRef.current + const entry = { scope, text } + pendingDraftPersistRef.current = entry window.clearTimeout(draftPersistTimerRef.current) draftPersistTimerRef.current = window.setTimeout(() => { + // Integrity guard (defense-in-depth, #54527): only commit if this is + // still the pending write on file. A session swap or a newer + // keystroke clears/replaces it before firing in the normal case; this + // catches any future call site that skips that bookkeeping instead of + // silently filing text under the wrong session. + if (!isPendingDraftPersistCurrent(pendingDraftPersistRef.current, entry)) { + return + } + pendingDraftPersistRef.current = null stashAt(scope, text) }, DRAFT_PERSIST_DEBOUNCE_MS) @@ -274,17 +299,25 @@ export function useComposerDraft({ insertInlineRefsRef.current = insertInlineRefs useEffect(() => { - return onComposerInsertRefsRequest(({ refs, target }) => { - if (target === 'main') { + return onComposerInsertRefsRequest(({ refs, target: requested }) => { + if (requested === target) { insertInlineRefsRef.current(refs) } }) - }, []) + }, [target]) // Per-thread draft swap — the composer's only session coupling. Lifecycle // never clears composer state; this effect alone stashes on leave, restores // on enter. Keyed writes are idempotent, so no skip-sentinel. useEffect(() => { + // A pending debounce timer from the outgoing session is now stale — its + // scope was correct when scheduled, but the authoritative stash below + // (and the cleanup on the way out) already covers that text. Letting it + // fire later would just clobber with an older snapshot. + window.clearTimeout(draftPersistTimerRef.current) + pendingDraftPersistRef.current = null + draftScopeRef.current = activeQueueSessionKey + const { attachments, text } = takeSessionDraft(activeQueueSessionKey) loadIntoComposer(text, attachments) @@ -304,7 +337,7 @@ export function useComposerDraft({ // inside the debounce/rAF window would drop trailing keystrokes without this. useEffect(() => { const flushPendingDraftPersist = () => { - const scope = activeQueueSessionKeyRef.current + const scope = draftScopeRef.current const editing = queueEditStateRef.current if (editing?.sessionKey === scope || isBrowsingHistory(sessionIdRef.current)) { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts index 37b3625a4f16..ff017edcf96e 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts @@ -2,10 +2,15 @@ import { useEffect, useRef } from 'react' import { triggerHaptic } from '@/lib/haptics' +import { type ComposerTarget, getActiveComposer } from '../focus' + interface UseComposerEscCancelOptions { awaitingInput: boolean busy: boolean onCancel: () => unknown + /** This composer's focus-bus key. With N composers mounted (main + tiles), + * only the active one's Esc cancels — otherwise every busy tile stops. */ + target: ComposerTarget } /** @@ -15,7 +20,7 @@ interface UseComposerEscCancelOptions { * the window listener registered exactly once while still reading fresh * busy/awaitingInput/onCancel each press. */ -export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseComposerEscCancelOptions) { +export function useComposerEscCancel({ awaitingInput, busy, onCancel, target }: UseComposerEscCancelOptions) { // Intentional only: we bail if (a) the composer/another field already handled // Esc (defaultPrevented), (b) focus is in any input/textarea/contenteditable // (you're typing, not stopping), or (c) a dialog/popover is open — Esc must @@ -30,6 +35,12 @@ export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseCompo return } + // Only the focused composer cancels — otherwise every mounted busy tile + // stops at once (and the winner would be mount-order arbitrary). + if (getActiveComposer() !== target) { + return + } + const active = document.activeElement as HTMLElement | null if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts index da66ddd843aa..318802bbab80 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -6,7 +6,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer' import { $composerPoppedOut } from '@/store/composer-popout' import { isSecondaryWindow } from '@/store/windows' -import { COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils' +import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils' interface UseComposerMetricsArgs { composerRef: RefObject<HTMLFormElement | null> @@ -24,10 +24,13 @@ interface UseComposerMetricsArgs { * Returns `stacked` (the only value the render needs). */ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }: UseComposerMetricsArgs): { + compactPill: boolean stacked: boolean } { const [expanded, setExpanded] = useState(false) const [tight, setTight] = useState(false) + // Wider than `tight`: the pill goes icon-only before the row has to stack. + const [compactPill, setCompactPill] = useState(false) const narrow = useMediaQuery('(max-width: 30rem)') // Edge signals, not the live text: these only re-render when emptiness / the @@ -72,6 +75,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, const lastBucketedHeightRef = useRef(0) const lastBucketedSurfaceHeightRef = useRef(0) const lastTightRef = useRef<boolean | null>(null) + const lastCompactPillRef = useRef<boolean | null>(null) const syncComposerMetrics = useCallback(() => { const composer = composerRef.current @@ -105,6 +109,13 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, lastTightRef.current = nextTight setTight(nextTight) } + + const nextCompactPill = width < COMPOSER_COMPACT_PILL_PX + + if (nextCompactPill !== lastCompactPillRef.current) { + lastCompactPillRef.current = nextCompactPill + setCompactPill(nextCompactPill) + } } // Expand once the input has actually wrapped past a single line. The @@ -156,5 +167,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, } }, []) - return { stacked: expanded || narrow || tight } + // Pill compacts on real width (tile/pane), OR when stacked for any reason + // (viewport-narrow / wrapped) so the controls row never over-runs. + return { compactPill: compactPill || narrow || tight, stacked: expanded || narrow || tight } } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts index 3fb0d0372f88..518aa3658a56 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts @@ -11,6 +11,8 @@ import { } from '@/store/composer-popout' import { isSecondaryWindow } from '@/store/windows' +import { useComposerScope } from '../scope' + import { useComposerPopoutGestures } from './use-popout-drag' interface UseComposerPopoutOptions { @@ -25,7 +27,10 @@ interface UseComposerPopoutOptions { * window's composer out via the shared atom. */ export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) { - const popoutAllowed = !isSecondaryWindow() + // The floating composer is a window-level singleton: only the main scope + // (not tiles) in a primary window may pop out. + const scope = useComposerScope() + const popoutAllowed = !isSecondaryWindow() && scope.popoutAllowed const poppedOut = useStore($composerPoppedOut) && popoutAllowed const popoutPosition = useStore($composerPopoutPosition) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts index c40d56a4826b..761d6830a1bd 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -3,7 +3,7 @@ import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { useSessionSlice } from '@/lib/use-session-slice' -import { clearComposerAttachments, type ComposerAttachment } from '@/store/composer' +import { type ComposerAttachment } from '@/store/composer' import { resetBrowseState } from '@/store/composer-input-history' import { $queuedPromptsBySession, @@ -19,6 +19,7 @@ import { import { notify } from '@/store/notifications' import { cloneAttachments, type QueueEditState } from '../composer-utils' +import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' interface UseComposerQueueArgs { @@ -60,6 +61,7 @@ export function useComposerQueue({ sessionId }: UseComposerQueueArgs) { const { t } = useI18n() + const scope = useComposerScope() // Per-session slice (edge): re-renders only when THIS session's queue changes, // not on cross-session queue churn (the plain atom's map ref changes on every @@ -173,11 +175,11 @@ export function useComposerQueue({ } clearDraft() - clearComposerAttachments() + scope.attachments.clear() triggerHaptic('selection') return true - }, [activeQueueSessionKey, attachments, clearDraft, draftRef]) + }, [activeQueueSessionKey, attachments, clearDraft, draftRef, scope.attachments]) // All queue drain paths share one lock + send-then-remove sequence. // `pickEntry` lets each caller choose head, by-id, or skip-edited. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index eab822d7cd89..adf44e34a8d7 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -2,13 +2,14 @@ import { type RefObject, useEffect, useRef } from 'react' import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' -import { clearComposerAttachments, clearSessionDraft, type ComposerAttachment } from '@/store/composer' +import { clearSessionDraft, type ComposerAttachment } from '@/store/composer' import { resetBrowseState } from '@/store/composer-input-history' import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue' import { cloneAttachments, type QueueEditState } from '../composer-utils' import { onComposerSubmitRequest } from '../focus' import { composerPlainText } from '../rich-editor' +import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' interface UseComposerSubmitArgs { @@ -71,6 +72,8 @@ export function useComposerSubmit({ setComposerText, stashAt }: UseComposerSubmitArgs) { + const scope = useComposerScope() + // Shared send primitive: fire onSubmit, and if the gateway rejects (accepted // === false) or throws, re-load + re-stash the draft so the words survive. const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => { @@ -79,7 +82,11 @@ export function useComposerSubmit({ const restore = () => { loadIntoComposer(text, submittedAttachments) - stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments) + // Use the scope captured at dispatch, not whatever session is focused + // now — the gateway can reject well after the user has switched away, + // and re-stashing into the currently-focused session would overwrite + // its draft with the rejected text from a different session (#54527). + stashAt(submittedScope, text, submittedAttachments) } void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) @@ -159,7 +166,7 @@ export function useComposerSubmit({ triggerHaptic('submit') resetBrowseState(sessionId) clearDraft() - clearComposerAttachments() + scope.attachments.clear() dispatchSubmit(text, submittedAttachments) } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 2cff7a4084c7..5ce92171d3f1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -8,6 +8,7 @@ import { notifyError } from '@/store/notifications' import { $messages } from '@/store/session' import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' +import type { ComposerTarget } from '../focus' import { onComposerVoiceToggleRequest } from '../focus' import type { ChatBarProps } from '../types' @@ -25,6 +26,9 @@ interface UseComposerVoiceArgs { onSubmit: ChatBarProps['onSubmit'] onTranscribeAudio: ChatBarProps['onTranscribeAudio'] sessionId: string | null | undefined + /** This composer's focus-bus key — voice toggles targeting another + * composer (or the active one, when not us) are ignored. */ + target: ComposerTarget } /** @@ -42,7 +46,8 @@ export function useComposerVoice({ maxRecordingSeconds, onSubmit, onTranscribeAudio, - sessionId + sessionId, + target }: UseComposerVoiceArgs) { const { t } = useI18n() const [voiceConversationActive, setVoiceConversationActive] = useState(false) @@ -122,7 +127,10 @@ export function useComposerVoice({ } }, [conversation, disabled, voiceConversationActive]) - useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation]) + useEffect( + () => onComposerVoiceToggleRequest(toggled => toggled === target && toggleVoiceConversation()), + [target, toggleVoiceConversation] + ) // Explicit start/end for the on-screen conversation controls (the hotkey uses // the gated toggle above). diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 1f5df46eb2a4..41e1c813309b 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,21 +1,21 @@ import { ComposerPrimitive } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useEffect, useRef } from 'react' +import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react' import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' +import { Slot as ContribSlot } from '@/contrib/react/slot' import { useI18n } from '@/i18n' import { chatMessageText } from '@/lib/chat-messages' +import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { $composerAttachments } from '@/store/composer' import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history' import { POPOUT_WIDTH_REM } from '@/store/composer-popout' import { removeQueuedPrompt } from '@/store/composer-queue' -import { $activeSessionAwaitingInput } from '@/store/prompts' import { toggleReview } from '@/store/review' -import { $gatewayState, $messages } from '@/store/session' +import { $gatewayState } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' import { $autoSpeakReplies } from '@/store/voice-prefs' import { useTheme } from '@/themes' @@ -23,6 +23,7 @@ import { useTheme } from '@/themes' import { AttachmentList } from './attachments' import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils' import { ContextMenu } from './context-menu' +import { COMPOSER_AREAS, runComposerMiddleware } from './contrib' import { ComposerControls } from './controls' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance' import { markActiveComposer } from './focus' @@ -51,6 +52,7 @@ import { normalizeComposerEditorDom, RICH_INPUT_SLOT } from './rich-editor' +import { useComposerScope } from './scope' import { ComposerStatusStack } from './status-stack' import { CodingStatusRow } from './status-stack/coding-row' import { extractClipboardImageBlobs } from './text-utils' @@ -79,17 +81,36 @@ export function ChatBar({ onPickImages, onRemoveAttachment, onSteer, - onSubmit, + onSubmit: onSubmitProp, onTranscribeAudio }: ChatBarProps) { - const attachments = useStore($composerAttachments) + // Every send (typed, queued, voice) passes through the contributed + // middleware chain first — rewrite / pass-through / cancel. Empty chain = + // exact pass-through, so surfaces without contributions are byte-identical. + const onSubmit = useCallback<ChatBarProps['onSubmit']>( + async (value, options) => { + const draft = await runComposerMiddleware({ text: value, attachments: options?.attachments }) + + if (!draft) { + return false + } + + return onSubmitProp(draft.text, { ...options, attachments: draft.attachments }) + }, + [onSubmitProp] + ) + + // Which live composer this instance IS (main | tile) — its attachment set, + // focus-bus key, and awaiting-input edge. Main scope = the legacy globals. + const scope = useComposerScope() + const attachments = useStore(scope.attachments.$attachments) const scrolledUp = useStore($threadScrolledUp) const autoSpeak = useStore($autoSpeakReplies) // The turn is parked on the user (clarify / approval / sudo / secret). Esc must // not interrupt it — there's nothing actively running to stop, and stopping // would discard a question the user may want to come back to. The blocking // prompt owns its own dismissal (Skip, Reject, dialog close). - const awaitingInput = useStore($activeSessionAwaitingInput) + const awaitingInput = useStore(scope.$awaitingInput) const activeQueueSessionKey = queueSessionKey || sessionId || null // Status items (subagents, background processes) are keyed by the RUNTIME @@ -188,7 +209,7 @@ export function ChatBar({ const statusStackVisible = queuedPrompts.length > 0 || statusPresent - const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }) + const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }) const hasComposerPayload = hasText || attachments.length > 0 const canSubmit = busy || hasComposerPayload const busyAction = busy && hasComposerPayload ? 'queue' : 'stop' @@ -197,8 +218,6 @@ export function ChatBar({ // into a tool result) and never for a slash command (those execute inline). const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText - const showHelpHint = isHelpHint - // The submit engine — the orchestration seam where draft + queue meet. Owns // the submit decision tree, the send-with-restore primitive, and steer. const { steerDraft, submitDraft } = useComposerSubmit({ @@ -267,7 +286,7 @@ export function ChatBar({ normalizeComposerEditorDom(editor) - const nextDraft = composerPlainText(editor) + const nextDraft = sanitizeComposerInput(composerPlainText(editor)) if (nextDraft !== draftRef.current) { draftRef.current = nextDraft @@ -332,7 +351,7 @@ export function ChatBar({ // blank lines (common when selecting from terminals, code blocks, web pages) // doesn't dump multiline padding into the composer. Internal newlines are // preserved — only the edges are cleaned up. - const pastedText = event.clipboardData.getData('text').trim() + const pastedText = sanitizeComposerInput(event.clipboardData.getData('text').trim()) if (!pastedText) { event.preventDefault() @@ -506,11 +525,11 @@ export function ChatBar({ // $messages is read imperatively (not subscribed) so the composer // doesn't re-render on every streaming delta flush. - const history = deriveUserHistory($messages.get(), chatMessageText) + const history = deriveUserHistory(scope.readMessages(), chatMessageText) const entry = browseBackward(sessionId, currentDraft, history) if (entry !== null) { - loadIntoComposer(entry, $composerAttachments.get()) + loadIntoComposer(entry, scope.attachments.$attachments.get()) } return @@ -531,11 +550,11 @@ export function ChatBar({ event.preventDefault() triggerKeyConsumedRef.current = true - const history = deriveUserHistory($messages.get(), chatMessageText) + const history = deriveUserHistory(scope.readMessages(), chatMessageText) const result = browseForward(sessionId, history) if (result !== null) { - loadIntoComposer(result.text, $composerAttachments.get()) + loadIntoComposer(result.text, scope.attachments.$attachments.get()) } } @@ -643,7 +662,7 @@ export function ChatBar({ useComposerBranch({ clearDraft, cwd, draftRef }) // Global Esc-to-cancel when the chat (not the composer input) has focus. - useComposerEscCancel({ awaitingInput, busy, onCancel }) + useComposerEscCancel({ awaitingInput, busy, onCancel, target: scope.target }) const { conversation, @@ -663,7 +682,8 @@ export function ChatBar({ maxRecordingSeconds, onSubmit, onTranscribeAudio, - sessionId + sessionId, + target: scope.target }) const contextMenu = ( @@ -685,7 +705,7 @@ export function ChatBar({ busyAction={busyAction} canSteer={canSteer} canSubmit={canSubmit} - compactModelPill={poppedOut} + compactModelPill={poppedOut || compactPill} conversation={{ active: voiceConversationActive, level: conversation.level, @@ -741,7 +761,7 @@ export function ChatBar({ }} onDragOver={handleInputDragOver} onDrop={handleInputDrop} - onFocus={() => markActiveComposer('main')} + onFocus={() => markActiveComposer(scope.target)} onInput={handleEditorInput} onKeyDown={handleEditorKeyDown} onKeyUp={handleEditorKeyUp} @@ -845,7 +865,7 @@ export function ChatBar({ : undefined } > - {showHelpHint && <HelpHint />} + {isHelpHint && <HelpHint />} {trigger && !argStageEmpty && ( <ComposerTriggerPopover activeIndex={triggerActive} @@ -935,6 +955,10 @@ export function ChatBar({ )} data-slot="composer-fade" > + {/* Contribution seams: banners above, a row below, inline + additions beside the "+" menu and before the controls. + All four render nothing until something contributes. */} + <ContribSlot area={COMPOSER_AREAS.top} /> <VoiceActivity state={voiceActivityState} /> <VoicePlaybackActivity /> {queueEdit && editingQueuedPrompt && ( @@ -970,10 +994,17 @@ export function ChatBar({ : 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]' )} > - <div className="flex translate-y-[3px] items-start self-start [grid-area:menu]">{contextMenu}</div> + <div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]"> + {contextMenu} + <ContribSlot area={COMPOSER_AREAS.leading} /> + </div> <div className="min-w-0 [grid-area:input]">{input}</div> - <div className="flex items-center justify-end [grid-area:controls]">{controls}</div> + <div className="flex items-center justify-end gap-(--composer-control-gap) [grid-area:controls]"> + <ContribSlot area={COMPOSER_AREAS.actions} /> + {controls} + </div> </div> + <ContribSlot area={COMPOSER_AREAS.bottom} /> </div> </div> </div> diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts index ac04bfacbc6f..5fd62f4cc94a 100644 --- a/apps/desktop/src/app/chat/composer/inline-refs.ts +++ b/apps/desktop/src/app/chat/composer/inline-refs.ts @@ -1,4 +1,5 @@ import { formatRefValue } from '@/components/assistant-ui/directive-text' +import { translateNow } from '@/i18n' import { contextPath } from '@/lib/chat-runtime' import type { DroppedFile } from '../hooks/use-composer-actions' @@ -8,44 +9,22 @@ import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipEl /** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */ export type InlineRefInput = string | { kind: string; label?: string; value: string } -/** MIME for an in-app session drag (sidebar row → composer). */ -export const HERMES_SESSION_MIME = 'application/x-hermes-session' - +/** A dragged sidebar session — carried in-memory by the pointer drag session + * (session-drag.ts); sessions never ride native DnD. */ export interface SessionDragPayload { id: string profile: string title: string } -export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) { - transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload)) - transfer.effectAllowed = 'copy' -} - -export function dragHasSession(transfer: DataTransfer | null) { - return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME) -} - -export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload { - const raw = transfer?.getData(HERMES_SESSION_MIME) - - if (!raw) { - return null - } - - try { - const parsed = JSON.parse(raw) as Partial<SessionDragPayload> - - return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null - } catch { - return null - } -} +/** A session's friendly display label — its title, or a localized fallback. */ +export const sessionLabel = ({ id, title }: SessionDragPayload) => + title || translateNow('sidebar.row.untitledChat', id.slice(0, 8)) /** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent * needs to resolve the link (session_search); label shows the friendly title. */ -export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput { - return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` } +export function sessionInlineRef(payload: SessionDragPayload): InlineRefInput { + return { kind: 'session', label: sessionLabel(payload), value: `${payload.profile || 'default'}/${payload.id}` } } export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) { diff --git a/apps/desktop/src/app/chat/composer/scope.tsx b/apps/desktop/src/app/chat/composer/scope.tsx new file mode 100644 index 000000000000..67581a39e30d --- /dev/null +++ b/apps/desktop/src/app/chat/composer/scope.tsx @@ -0,0 +1,47 @@ +import type { ReadableAtom } from 'nanostores' +import { createContext, useContext } from 'react' + +import type { ChatMessage } from '@/lib/chat-messages' +import { type ComposerAttachmentScope, mainComposerScope } from '@/store/composer' +import { $activeSessionAwaitingInput } from '@/store/prompts' +import { $messages } from '@/store/session' + +import type { ComposerTarget } from './focus' + +/** + * COMPOSER SCOPE — which live composer a ChatBar instance IS. The main chat's + * ChatBar runs in the default scope (module-level attachment atom, focus-bus + * target 'main', the active session's awaiting-input edge). A session tile + * mounts its ChatBar under its own scope, so N composers coexist: separate + * attachment chips, separate focus/insert routing, separate Esc semantics. + * + * Draft TEXT needs no scoping — it lives in each ChatBar's contentEditable + + * draftRef and stashes per session key (`stashSessionDraft`), which already + * differs per surface. + */ +export interface ComposerScope { + /** This scope's "turn parked on user input" edge — gates Esc-to-stop. */ + $awaitingInput: ReadableAtom<boolean> + attachments: ComposerAttachmentScope + /** Only the main scope may pop out (the floating composer is a singleton). */ + popoutAllowed: boolean + /** Imperative read of this scope's transcript (input-history browse) — + * never subscribed, so streaming stays out of the composer's renders. */ + readMessages: () => ChatMessage[] + /** Focus-bus routing key (`'main'` | `'tile:<id>'`). */ + target: ComposerTarget +} + +export const MAIN_COMPOSER_SCOPE: ComposerScope = { + $awaitingInput: $activeSessionAwaitingInput, + attachments: mainComposerScope, + popoutAllowed: true, + readMessages: () => $messages.get(), + target: 'main' +} + +const ComposerScopeContext = createContext<ComposerScope>(MAIN_COMPOSER_SCOPE) + +export const ComposerScopeProvider = ComposerScopeContext.Provider + +export const useComposerScope = (): ComposerScope => useContext(ComposerScopeContext) diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.test.tsx new file mode 100644 index 000000000000..3bc38ed92b06 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.test.tsx @@ -0,0 +1,30 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { PreviewStatusRow } from './preview-row' + +describe('PreviewStatusRow', () => { + afterEach(() => { + cleanup() + }) + + it('keeps the preview tooltip label inline inside the portaled decoration', async () => { + const view = render( + <PreviewStatusRow + item={{ cwd: 'C:\\repo', id: 'preview.html', label: 'preview.html', target: 'preview.html' }} + onDismiss={() => undefined} + /> + ) + + fireEvent.pointerMove(screen.getByText('preview.html'), { pointerType: 'mouse' }) + await screen.findByRole('tooltip') + + const content = document.querySelector<HTMLElement>('[data-slot="tooltip-content"]') + const label = content?.firstElementChild?.firstElementChild + + expect(content).not.toBeNull() + expect(view.container.contains(content)).toBe(false) + expect(label?.classList.contains('inline-flex')).toBe(true) + expect(label?.classList.contains('flex')).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx index 5e5593651120..cf721d2ae936 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx @@ -113,7 +113,9 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss > <Tip label={ - <span className="flex flex-col gap-0.5"> + // inline-flex (not flex): a block child collapses Tip's decoration + // wrapper geometry and mis-positions the tooltip (#62022). + <span className="inline-flex flex-col gap-0.5"> <span>{item.target}</span> <span className="opacity-70">{t.preview.linkHint}</span> </span> diff --git a/apps/desktop/src/app/chat/composer/text-utils.test.ts b/apps/desktop/src/app/chat/composer/text-utils.test.ts index f80e6db4385d..6c6a20780f64 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts @@ -46,6 +46,14 @@ describe('detectTrigger', () => { expect(detectTrigger('/path/to/file')).toBeNull() }) + it('does not trigger slash popover mid-message', () => { + expect(detectTrigger('hello /')).toBeNull() + expect(detectTrigger('hello /skill')).toBeNull() + expect(detectTrigger('hello there /personality alic')).toBeNull() + expect(detectTrigger('text\n/skill')).toBeNull() + expect(detectTrigger('multi word message /')).toBeNull() + }) + it('still anchors at-mention triggers strictly at the token edge', () => { expect(detectTrigger('@file:path with space')).toBeNull() }) diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index 4535d6963c3a..b9b6adc07f1e 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -11,8 +11,12 @@ export interface TriggerState { // user types args (`/personality alic` → arg completer suggests `alice`). // Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file // paths like `src/foo/bar`. +// +// Slash commands only execute at the beginning of a message, so the `/` +// trigger is anchored strictly at position 0 — not after whitespace — to +// avoid opening the popover mid-message (e.g. `hello /`). const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/ -const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ +const SLASH_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ /** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */ export function blobDedupeKey(blob: Blob): string { diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index d510c59f45b4..2f9a924a0a92 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -255,22 +255,46 @@ export function partitionDroppedFiles(candidates: DroppedFile[]): { return { osDrops, inAppRefs } } +/** The composer these actions feed. Defaults to the main chat's scope; + * session tiles pass their own so picks/drops/pastes land in THEIR chips. */ +interface ComposerActionsScope { + add: (attachment: ComposerAttachment) => void + remove: (id: string) => ComposerAttachment | null + target: string +} + +const MAIN_ACTIONS_SCOPE: ComposerActionsScope = { + add: addComposerAttachment, + remove: removeComposerAttachment, + target: 'main' +} + interface ComposerActionsOptions { activeSessionId: string | null currentCwd: string requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> + scope?: ComposerActionsScope } -/** Add to the main composer and focus it. All sidebar/picker/drop attach paths funnel through here. */ -const attachToMain = (attachment: ComposerAttachment) => { - addComposerAttachment(attachment) - requestComposerFocus('main') -} - -export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) { +export function useComposerActions({ + activeSessionId, + currentCwd, + requestGateway, + scope = MAIN_ACTIONS_SCOPE +}: ComposerActionsOptions) { const { t } = useI18n() const copy = t.desktop + /** Add to this scope's composer and focus it. All sidebar/picker/drop + * attach paths funnel through here. */ + const attachToMain = useCallback( + (attachment: ComposerAttachment) => { + scope.add(attachment) + requestComposerFocus(scope.target) + }, + [scope] + ) + const addTextToDraft = useCallback((text: string) => { requestComposerInsert(text, { mode: 'block' }) }, []) @@ -302,7 +326,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway detail, refText }) - }, []) + }, [attachToMain]) const pickContextPaths = useCallback( async (kind: 'file' | 'folder') => { @@ -329,7 +353,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway }) } }, - [currentCwd] + [attachToMain, currentCwd] ) const insertContextPathInlineRef = useCallback( @@ -344,12 +368,12 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway return false } - requestComposerInsertRefs([ref]) - requestComposerFocus('main') + requestComposerInsertRefs([ref], { target: scope.target }) + requestComposerFocus(scope.target) return true }, - [currentCwd] + [currentCwd, scope.target] ) const attachContextFilePath = useCallback( @@ -371,7 +395,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway return true }, - [currentCwd] + [attachToMain, currentCwd] ) const attachImagePath = useCallback( @@ -394,7 +418,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway const previewUrl = await attachmentPreviewDataUrl(filePath) if (previewUrl) { - addComposerAttachment({ ...baseAttachment, previewUrl }) + scope.add({ ...baseAttachment, previewUrl }) } return true @@ -404,7 +428,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway return true } }, - [copy.imagePreviewFailed] + [attachToMain, copy.imagePreviewFailed, scope] ) const attachImageBlob = useCallback( @@ -509,7 +533,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway return true }, - [currentCwd] + [attachToMain, currentCwd] ) const attachDroppedItems = useCallback( @@ -599,7 +623,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway const removeAttachment = useCallback( async (id: string) => { - const removed = removeComposerAttachment(id) + const removed = scope.remove(id) if ( removed?.kind === 'image' && @@ -614,7 +638,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway }).catch(() => undefined) } }, - [activeSessionId, requestGateway] + [activeSessionId, requestGateway, scope] ) return { diff --git a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts index 10b3cfe40a90..42e4554b992e 100644 --- a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts +++ b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts @@ -1,53 +1,75 @@ -import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react' +import { type DragEvent as ReactDragEvent, useCallback, useEffect, useRef, useState } from 'react' -import { - dragHasAttachments, - dragHasSession, - readSessionDrag, - type SessionDragPayload -} from '@/app/chat/composer/inline-refs' +import { dragHasAttachments } from '@/app/chat/composer/inline-refs' +import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers' import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions' +/** `'session'` is set by callers from the pointer drag session's store — + * native drags only ever resolve to `'files'` here (sessions left native + * DnD; see session-drag.ts). */ export type DragKind = 'files' | 'session' | null -const dragKindOf = (event: ReactDragEvent): DragKind => { - if (dragHasSession(event.dataTransfer)) { - return 'session' - } - - if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return 'files' - } - - return null -} +const dragKindOf = (event: ReactDragEvent): DragKind => + dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME) ? 'files' : null interface FileDropZoneOptions { /** When false the zone ignores drags entirely. */ enabled?: boolean onDropFiles: (files: DroppedFile[]) => void - onDropSession?: (session: SessionDragPayload) => void } /** - * "Drop anywhere in this region" affordance for files *and* in-app session - * links. An enter/leave depth counter keeps nested children from flickering the + * "Drop anywhere in this region" affordance for FILE drags — the one drag + * kind still on native DnD (Finder/OS drops and the project tree must be). + * An enter/leave depth counter keeps nested children from flickering the * active state; `onDropCapture` clears it even when a nested target (the * composer) handles the drop and stops propagation before our bubble-phase * `onDrop` would fire. * * Spread `dropHandlers` onto the container; render an overlay off `dragKind`. + * Esc aborts an in-flight drag, matching the sidebar session drag. */ -export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) { +export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) { const [dragKind, setDragKind] = useState<DragKind>(null) const depth = useRef(0) + const aborted = useRef(false) const reset = useCallback(() => { depth.current = 0 setDragKind(null) }, []) + // Esc aborts a file drag — the same "never mind" a session drag gets. Native + // DnD can't be cancelled at the OS level, so we drop the overlay and arm a + // guard that swallows the trailing drop instead. Top escape layer + capture + // stop so it doesn't also fire a handler behind the drag (see drag-session). + useEffect(() => { + if (dragKind === null) { + return + } + + const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag) + + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return + } + + event.preventDefault() + event.stopPropagation() + aborted.current = true + reset() + } + + window.addEventListener('keydown', onKey, true) + + return () => { + window.removeEventListener('keydown', onKey, true) + releaseLayer() + } + }, [dragKind, reset]) + const onDragEnter = useCallback( (event: ReactDragEvent) => { const kind = enabled ? dragKindOf(event) : null @@ -57,6 +79,12 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: } event.preventDefault() + + // A genuinely new drag (not a nested-child re-enter) re-arms after abort. + if (depth.current === 0) { + aborted.current = false + } + depth.current += 1 setDragKind(kind) }, @@ -89,16 +117,17 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: return } + // Only an Esc abort swallows the drop — NOT `event.defaultPrevented`. The + // file tree's app-wide react-dnd HTML5Backend preventDefaults every native + // file drop in the capture phase, so that flag is always set here (every + // Finder drop would no-op). Genuine nested targets claim via stopPropagation + // and never reach this bubble handler anyway. + const claimed = aborted.current + event.preventDefault() reset() - if (kind === 'session') { - const session = readSessionDrag(event.dataTransfer) - - if (session) { - onDropSession?.(session) - } - + if (claimed) { return } @@ -108,7 +137,7 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: onDropFiles(files) } }, - [enabled, onDropFiles, onDropSession, reset] + [enabled, onDropFiles, reset] ) return { diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 74eb8df86613..4b417404bedd 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -1,48 +1,38 @@ -import { - type AppendMessage, - AssistantRuntimeProvider, - ExportedMessageRepository, - type ThreadMessage -} from '@assistant-ui/react' +import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type * as React from 'react' -import { Suspense, useCallback, useMemo, useRef } from 'react' +import { Suspense, useCallback, useMemo } from 'react' import { useLocation } from 'react-router-dom' import { Thread } from '@/components/assistant-ui/thread' import { Backdrop } from '@/components/Backdrop' +import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts' +import { $sessionTileDragging, $sessionTileEdgeHover } from '@/components/pane-shell/tree/store' import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' -import { Codicon } from '@/components/ui/codicon' import { ErrorState } from '@/components/ui/error-state' +import { TitleMenuTrigger } from '@/components/ui/title-menu-trigger' import { getGlobalModelOptions, type HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import type { ChatMessage } from '@/lib/chat-messages' -import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime' +import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' import { $pinnedSessionIds } from '@/store/layout' +import { $petActive } from '@/store/pet' +import { $petOverlayActive } from '@/store/pet-overlay' import { $gatewaySwapTarget } from '@/store/profile' import { - $activeSessionId, - $awaitingResponse, - $busy, $contextSuggestions, - $currentCwd, - $currentModel, - $currentProvider, $freshDraftReady, $gatewayState, $introPersonality, $introSeed, - $lastVisibleMessageIsUser, - $messages, - $messagesEmpty, $resumeExhaustedSessionId, - $selectedStoredSessionId, $sessions, + sessionMatchesStoredId, sessionPinId } from '@/store/session' import { isSecondaryWindow, isWatchWindow } from '@/store/windows' @@ -54,12 +44,15 @@ import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitle import { ChatDropOverlay } from './chat-drop-overlay' import { ChatSwapOverlay } from './chat-swap-overlay' import { ChatBar, ChatBarFallback } from './composer' -import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus' -import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs' +import { requestComposerInsert } from './composer/focus' +import { droppedFileInlineRefs } from './composer/inline-refs' +import { useComposerScope } from './composer/scope' import type { ChatBarState } from './composer/types' import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions' -import { useFileDropZone } from './hooks/use-file-drop-zone' +import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone' +import { useRuntimeMessageRepository } from './runtime-repository' import { ScrollToBottomButton } from './scroll-to-bottom-button' +import { useSessionView } from './session-view' import { SessionActionsMenu } from './sidebar/session-actions-menu' import { threadLoadingState } from './thread-loading' @@ -113,7 +106,7 @@ function ChatHeader({ const pinnedSessionIds = useStore($pinnedSessionIds) const activeStoredSession = - sessions.find(session => session.id === selectedSessionId || session._lineage_root_id === selectedSessionId) || null + (selectedSessionId && sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))) || null const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session' @@ -151,14 +144,7 @@ function ChatHeader({ sideOffset={8} title={title} > - <Button - className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]" - type="button" - variant="ghost" - > - <h2 className="min-w-0 flex-1 truncate text-[0.75rem] font-medium leading-none">{title}</h2> - <Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" /> - </Button> + <TitleMenuTrigger>{title}</TitleMenuTrigger> </SessionActionsMenu> </div> </header> @@ -198,44 +184,9 @@ function ChatRuntimeBoundary({ onThreadMessagesChange, suppressMessages }: ChatRuntimeBoundaryProps) { - const storeMessages = useStore($messages) + const storeMessages = useStore(useSessionView().$messages) const messages = suppressMessages ? NO_MESSAGES : storeMessages - const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>()) - - const runtimeMessageRepository = useMemo(() => { - const items: { message: ThreadMessage; parentId: string | null }[] = [] - const branchParentByGroup = new Map<string, string | null>() - let visibleParentId: string | null = null - let headId: string | null = null - - for (const message of messages) { - let parentId = visibleParentId - - if (message.role === 'assistant' && message.branchGroupId) { - if (!branchParentByGroup.has(message.branchGroupId)) { - branchParentByGroup.set(message.branchGroupId, visibleParentId) - } - - parentId = branchParentByGroup.get(message.branchGroupId) ?? null - } - - const cachedMessage = runtimeMessageCacheRef.current.get(message) - const runtimeMessage = cachedMessage ?? toRuntimeMessage(message) - - if (!cachedMessage) { - runtimeMessageCacheRef.current.set(message, runtimeMessage) - } - - items.push({ message: runtimeMessage, parentId }) - - if (!message.hidden) { - visibleParentId = message.id - headId = message.id - } - } - - return ExportedMessageRepository.fromBranchableArray(items, { headId }) - }, [messages]) + const runtimeMessageRepository = useRuntimeMessageRepository(messages) const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({ messageRepository: runtimeMessageRepository, @@ -283,29 +234,46 @@ export function ChatView({ }: ChatViewProps) { const location = useLocation() const { t } = useI18n() - const activeSessionId = useStore($activeSessionId) - const awaitingResponse = useStore($awaitingResponse) - const busy = useStore($busy) + // The view this surface renders: the primary route-driven session (global + // atoms) or a tile's session slice — same component either way. + const view = useSessionView() + const composerScope = useComposerScope() + const isPrimary = view.kind === 'primary' + const activeSessionId = useStore(view.$runtimeId) + const storedId = useStore(view.$storedId) + // Dock anchor for a session drop onto this surface: the workspace pane for the + // primary, this tile's pane id for a tile. Read by the session-drop bridge. + const sessionAnchor = isPrimary ? 'workspace' : `session-tile:${storedId ?? ''}` + const awaitingResponse = useStore(view.$awaitingResponse) + const busy = useStore(view.$busy) const contextSuggestions = useStore($contextSuggestions) - const currentCwd = useStore($currentCwd) - const currentModel = useStore($currentModel) - const currentProvider = useStore($currentProvider) + // Per-session (SessionView) reads — a tile IS its session, so these come + // from the view slice, not the global atoms (which track the primary only). + const currentCwd = useStore(view.$cwd) + const currentModel = useStore(view.$model) + const currentProvider = useStore(view.$provider) + // A pet anywhere (in-window or popped out) owns the hearts; composer only when none. + const petActive = useStore($petActive) + const petOverlayActive = useStore($petOverlayActive) + const petPresent = petActive || petOverlayActive const freshDraftReady = useStore($freshDraftReady) const gatewayState = useStore($gatewayState) const gatewaySwapTarget = useStore($gatewaySwapTarget) const gatewayOpen = gatewayState === 'open' const introPersonality = useStore($introPersonality) const introSeed = useStore($introSeed) - // PERF: ChatView must not subscribe to $messages — the atom is replaced on - // every streaming delta flush (~30×/s) and a subscription here re-renders - // the entire chat shell (header, chat bar, thread wrapper) per token. The - // runtime that DOES need the messages lives in ChatRuntimeBoundary below; - // this component only needs streaming-stable derivations. - const messagesEmpty = useStore($messagesEmpty) - const lastVisibleIsUser = useStore($lastVisibleMessageIsUser) - const selectedSessionId = useStore($selectedStoredSessionId) + // PERF: ChatView must not subscribe to the view's $messages — the atom is + // replaced on every streaming delta flush (~30×/s) and a subscription here + // re-renders the entire chat shell (header, chat bar, thread wrapper) per + // token. The runtime that DOES need the messages lives in + // ChatRuntimeBoundary below; this component only needs streaming-stable + // derivations. + const messagesEmpty = useStore(view.$messagesEmpty) + const lastVisibleIsUser = useStore(view.$lastVisibleIsUser) + const selectedSessionId = useStore(view.$storedId) const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) - const routedSessionId = routeSessionId(location.pathname) + // A tile IS its session — no route involved, never "mismatched". + const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId const isRoutedSessionView = Boolean(routedSessionId) // The URL points at a session the store hasn't loaded yet (sidebar / cmd-K / @@ -317,6 +285,7 @@ export function ChatView({ // The compact new-session pop-out skips the wordmark/tagline intro — it's a // scratch window, not the full-height empty state. const showIntro = + isPrimary && !isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && @@ -335,7 +304,7 @@ export function ChatView({ // Suppress the loader and show an explicit error + manual Retry instead of // spinning forever. Gated on the route matching so a stale latch from another // session can't blank the current one. - const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId + const resumeExhausted = isPrimary && isRoutedSessionView && resumeExhaustedSessionId === routedSessionId const loadingSession = !resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId)) @@ -406,23 +375,33 @@ export function ChatView({ const refs = droppedFileInlineRefs(inAppRefs, currentCwd) if (refs.length) { - requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' }) + requestComposerInsert(refs.join(' '), { mode: 'inline', target: composerScope.target }) } if (osDrops.length) { void onAttachDroppedItems(osDrops) } }, - [currentCwd, onAttachDroppedItems] + [composerScope.target, currentCwd, onAttachDroppedItems] ) - // Dropping a sidebar session inserts an @session link the agent can resolve - // via session_search (carries the source profile, so cross-profile works). - const onDropSession = useCallback((session: SessionDragPayload) => { - requestComposerInsertRefs([sessionInlineRef(session)], { target: 'main' }) - }, []) + // Session drags are POINTER drags (session-drag.ts) — never native DnD. + // The drop zone below only handles files; session drops commit through the + // drag session itself, which routes a center/link drop to this surface's + // composer via `data-composer-target`. + const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles }) - const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession }) + // While a session drag targets one of this surface's EDGES or a tab strip, + // the zone overlay/caret owns the visual — the link overlay stands down. + // It shows for the whole drag on every chat surface otherwise (the drag + // session's global sentinel, not a per-surface hover chain). + // COMPUTED booleans, never the raw `$dropHint`: the hint churns on every + // pointer-crossing of every drag (pane drags included), and a re-render + // here is the WHOLE surface — thread, composer, header — per mounted tile. + const sessionDragging = useStore($sessionTileDragging) + const sessionEdgeHover = useStore($sessionTileEdgeHover) + + const overlayKind: DragKind = dragKind === 'files' ? 'files' : sessionDragging && !sessionEdgeHover ? 'session' : null return ( <div @@ -430,17 +409,26 @@ export function ChatView({ 'relative isolate flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className )} + data-composer-target={composerScope.target} + data-session-anchor={sessionAnchor} > <Backdrop /> - <ChatHeader - activeSessionId={activeSessionId} - isRoutedSessionView={isRoutedSessionView} - onDeleteSelectedSession={onDeleteSelectedSession} - onToggleSelectedPin={onToggleSelectedPin} - selectedSessionId={selectedSessionId} - /> + {/* Tiles get their chrome from the layout zone (chip strip); the modal + prompt overlays stay active-session-scoped in the primary surface. */} + {isPrimary && ( + <ChatHeader + activeSessionId={activeSessionId} + isRoutedSessionView={isRoutedSessionView} + onDeleteSelectedSession={onDeleteSelectedSession} + onToggleSelectedPin={onToggleSelectedPin} + selectedSessionId={selectedSessionId} + /> + )} - <PromptOverlays /> + {/* Mounted for the primary AND every tile, each scoped to its own session + so a tiled/background session's blocking prompt surfaces instead of + stalling to timeout. */} + <PromptOverlays sessionId={activeSessionId} /> <ChatRuntimeBoundary busy={busy} @@ -484,7 +472,21 @@ export function ChatView({ </div> )} {showChatBar && <ScrollToBottomButton />} - <ChatDropOverlay kind={dragKind} /> + {/* Vibe hearts rise from the composer only when no pet is out (else + they play on the pet). Fired by the core `reaction` event. */} + {!petPresent && ( + <HeartField + className="absolute inset-x-0 z-30" + config={COMPOSER_HEART_CONFIG} + style={{ + top: 0, + bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.25rem)' + }} + /> + )} + {/* A session drag hovering an EDGE hands the visual to the zone + target; the link overlay shows only for the center region. */} + <ChatDropOverlay kind={overlayKind} /> <ChatSwapOverlay profile={gatewaySwapTarget} /> </div> {/* Composer renders OUTSIDE the contain:[layout paint] wrapper above: diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts new file mode 100644 index 000000000000..8e8a96836702 --- /dev/null +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -0,0 +1,121 @@ +/** + * Mirror a reactive list of "tiles" into layout-tree pane contributions: + * register a pane per tile, refresh its title in place, and dispose panes whose + * tile is gone. This is the shared bookkeeping — a keyed registry, a wanted-set + * diff, a one-time pane closer — behind BOTH session tiles and route (page) + * tiles; each supplies only what differs (key, title, render, close, edge). + */ + +import type { ReadableAtom } from 'nanostores' +import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from 'react' + +import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session' +import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store' +import { registry } from '@/contrib/registry' +import type { TileDock } from '@/store/session-states' + +export interface PaneMirror<T> { + /** Reactive source list. */ + source: ReadableAtom<T[]> + /** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */ + also?: ReadableAtom<unknown>[] + /** Stable key + pane-id seed for a tile. */ + key: (tile: T) => string + /** Pane-id namespace — the id is `${prefix}:${key}`. */ + prefix: string + /** Dock on adoption (default right; `center` = stack into anchor's zone). */ + dir?: (tile: T) => TileDock | undefined + /** Pane to dock against (default `workspace`) — a drop's target zone. */ + anchor?: (tile: T) => string | undefined + /** Center docks: the strip slot (stack before this pane id). */ + before?: (tile: T) => null | string | undefined + minWidth: string + title: (key: string) => string + render: (key: string) => ReactNode + /** Wrap the tile's TAB (domain context menu — session verbs). */ + tabWrap?: (key: string, tab: ReactElement) => ReactNode + /** Override the tile's TAB drag (session drop language: stack/split/link). + * Returns whether it took the drag (see PaneChrome.tabDrag). */ + tabDrag?: ( + key: string, + event: ReactPointerEvent<HTMLElement>, + onTap: () => void, + double?: DoubleTapContext + ) => boolean + /** Wired as the pane's closer (tab Close). */ + close: (key: string) => void +} + +/** Build a `watch*` fn: syncs once, then re-syncs on every source/also change. + * Module-level state lives in the returned closure, so call it once per app. */ +export function paneMirror<T>(cfg: PaneMirror<T>): () => void { + const registered = new Map<string, { dispose: () => void; title: string }>() + const paneId = (key: string) => `${cfg.prefix}:${key}` + + const sync = () => { + const tiles = cfg.source.get() + const wanted = new Set(tiles.map(cfg.key)) + + for (const tile of tiles) { + const key = cfg.key(tile) + const title = cfg.title(key) + const current = registered.get(key) + + // register() replaces same-id in place — safe for live title refreshes. + if (current && current.title === title) { + continue + } + + const dispose = registry.register({ + id: paneId(key), + area: 'panes', + title, + data: { + dock: { + before: cfg.before?.(tile), + pane: cfg.anchor?.(tile) ?? 'workspace', + pos: cfg.dir?.(tile) ?? 'right' + }, + minWidth: cfg.minWidth, + placement: 'main', + tabDrag: cfg.tabDrag + ? (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) => + cfg.tabDrag!(key, event, onTap, double) + : undefined, // returns boolean (handled) — see PaneChrome.tabDrag + tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined + }, + render: () => cfg.render(key) + }) + + registered.set(key, { dispose, title }) + + if (!current) { + registerPaneCloser(paneId(key), () => cfg.close(key)) + } + } + + for (const [key, entry] of registered) { + if (!wanted.has(key)) { + entry.dispose() + registered.delete(key) + removeTreePane(paneId(key)) + } + } + + // Prune tree panes the SHARED tree persisted for a tile we never registered + // this session and that isn't wanted now — a profile switch reloads with the + // other profile's tile panes still stacked in. (`registered` is empty after a + // reload, so the loop above can't catch these.) + for (const id of treePanesWithPrefix(`${cfg.prefix}:`)) { + if (!wanted.has(id.slice(cfg.prefix.length + 1))) { + removeTreePane(id) + } + } + } + + return () => { + sync() + cfg.source.listen(sync) + cfg.also?.forEach(atom => atom.listen(sync)) + } +} diff --git a/apps/desktop/src/app/chat/right-rail/index.ts b/apps/desktop/src/app/chat/right-rail/index.ts index 8bb73a68a891..c79955718d44 100644 --- a/apps/desktop/src/app/chat/right-rail/index.ts +++ b/apps/desktop/src/app/chat/right-rail/index.ts @@ -1 +1 @@ -export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './preview' +export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from './preview' diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index ca4c65f2e998..46afb0ede38f 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -498,8 +498,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language: event.preventDefault() event.stopPropagation() + // Insert into and focus the SAME composer — 'active' — so a tile that owns + // focus keeps it instead of the ref landing in a tile but main stealing focus. requestComposerInsertRefs([ref]) - requestComposerFocus('main') + requestComposerFocus('active') } window.addEventListener('keydown', onKeyDown, { capture: true }) diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx index 51e5539bac9e..650900a42e9d 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx @@ -19,7 +19,7 @@ describe('PreviewPane console state', () => { vi.unstubAllGlobals() }) - it('does not watch backend-only remote filesystem previews locally', () => { + it('does not watch backend-only remote filesystem previews locally', async () => { const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' })) const onPreviewFileChanged = vi.fn(() => vi.fn()) $connection.set({ mode: 'remote' } as never) @@ -31,38 +31,43 @@ describe('PreviewPane console state', () => { } }) - render( - <PreviewPane - setTitlebarToolGroup={vi.fn()} - target={{ - kind: 'file', - label: 'file.txt', - path: '/remote/file.txt', - previewKind: 'text', - source: '/remote/file.txt', - url: 'file:///remote/file.txt' - }} - /> - ) + await act(async () => { + render( + <PreviewPane + setTitlebarToolGroup={vi.fn()} + target={{ + kind: 'file', + label: 'file.txt', + path: '/remote/file.txt', + previewKind: 'text', + source: '/remote/file.txt', + url: 'file:///remote/file.txt' + }} + /> + ) + }) expect(watchPreviewFile).not.toHaveBeenCalled() expect(onPreviewFileChanged).not.toHaveBeenCalled() }) - it('does not rebuild the pane titlebar group for streamed console logs', () => { + it('does not rebuild the pane titlebar group for streamed console logs', async () => { const setTitlebarToolGroup = vi.fn() - const rendered = render( - <PreviewPane - setTitlebarToolGroup={setTitlebarToolGroup} - target={{ - kind: 'url', - label: 'Preview', - source: 'http://localhost:5174', - url: 'http://localhost:5174' - }} - /> - ) + let rendered!: ReturnType<typeof render> + await act(async () => { + rendered = render( + <PreviewPane + setTitlebarToolGroup={setTitlebarToolGroup} + target={{ + kind: 'url', + label: 'Preview', + source: 'http://localhost:5174', + url: 'http://localhost:5174' + }} + /> + ) + }) const initialCalls = setTitlebarToolGroup.mock.calls.length const webview = rendered.container.querySelector('webview') diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index 2b77007a7307..31fc5f6d6a74 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -10,6 +10,7 @@ import { ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu' +import { PANE_TAB_STRIP_LINE, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab' import { Tip } from '@/components/ui/tooltip' import { translateNow, useI18n } from '@/i18n' import { formatCombo } from '@/lib/keybinds/combo' @@ -38,14 +39,6 @@ import { PreviewPane } from './preview-pane' export const PREVIEW_RAIL_MIN_WIDTH = '18rem' export const PREVIEW_RAIL_MAX_WIDTH = '38rem' -const INTRINSIC = `clamp(${PREVIEW_RAIL_MIN_WIDTH}, 36vw, 32rem)` - -// Track for <Pane id="preview">. Folds the intrinsic clamp with a min-floor -// against --chat-min-width so the chat surface never gets squeezed below it. -// Subtracts the project browser width so preview yields rather than crushing -// the chat when both right-side panes are open. -export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0rem, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0rem) - var(--chat-min-width))))` - interface ChatPreviewRailProps { onRestartServer?: (url: string, context?: string) => Promise<string> setTitlebarToolGroup?: SetTitlebarToolGroup @@ -110,7 +103,12 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP // titlebar-height so it opens below the band. 0px elsewhere → unchanged. style={{ paddingTop: 'var(--right-rail-top-inset, 0px)' }} > - <div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)"> + <div + className={cn( + 'group/rail-tabs flex h-(--titlebar-height) shrink-0 bg-(--ui-sidebar-surface-background)', + PANE_TAB_STRIP_LINE + )} + > <div className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" role="tablist" @@ -124,67 +122,20 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP return ( <ContextMenu key={tab.id}> <ContextMenuTrigger asChild> - <div - className={cn( - 'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)', - active - ? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]' - : 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground' - )} - // Middle-click closes the tab, matching browser/IDE muscle - // memory. `onMouseDown` swallows the middle-button press so - // Chromium doesn't switch into autoscroll mode. - onAuxClick={event => { - if (event.button !== 1) { - return - } - - event.preventDefault() - closeRightRailTab(tab.id) - }} - onMouseDown={event => { - if (event.button === 1) { - event.preventDefault() - } - }} - > - {active && ( - <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" /> - )} + <PaneTab active={active} dirty={dirty} onClose={() => closeRightRailTab(tab.id)}> <Tip label={tab.target.path || tab.target.url || tab.label}> - <button + <PaneTabLabel aria-selected={active} - className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none" + as="button" + className="normal-case tracking-normal" onClick={() => selectRightRailTab(tab.id)} role="tab" type="button" > - <span className="block min-w-0 truncate">{tab.label}</span> - </button> + {tab.label} + </PaneTabLabel> </Tip> - <span - aria-hidden="true" - className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100" - /> - {dirty && ( - <span - aria-hidden="true" - className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center opacity-100 transition-opacity group-hover/tab:opacity-0 group-focus-within/tab:opacity-0" - > - {/* Amber (our warn color); a tab-bg ring + soft drop keeps it - legible where it overlaps the filename. */} - <span className="size-2 rounded-full bg-amber-500 shadow-[0_0_0_2px_var(--tab-bg),0_1px_2px_rgba(0,0,0,0.45)] dark:bg-amber-400" /> - </span> - )} - <button - aria-label={t.preview.closeTab(tab.label)} - className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100" - onClick={() => closeRightRailTab(tab.id)} - type="button" - > - <Codicon name="close" size="0.75rem" /> - </button> - </div> + </PaneTab> </ContextMenuTrigger> <ContextMenuContent> <ContextMenuItem onSelect={() => closeRightRailTab(tab.id)}> diff --git a/apps/desktop/src/app/chat/route-tile.tsx b/apps/desktop/src/app/chat/route-tile.tsx new file mode 100644 index 000000000000..b807f733bf62 --- /dev/null +++ b/apps/desktop/src/app/chat/route-tile.tsx @@ -0,0 +1,90 @@ +/** + * ROUTE (PAGE) TILES — a full-page view rendered as a layout-tree pane BESIDE + * the main thread, the page analog of session tiles. Built-in pages + * (Capabilities / Messaging / Artifacts) render their view; plugin pages render + * their `ROUTES_AREA` contribution. Lifecycle mirrors session tiles: + * `openRouteTile(path)` -> `watchRouteTiles` registers a pane docked beside + * main -> tree adoption lands it on the chosen edge; closing removes it. + */ + +import { lazy, type ReactNode, Suspense } from 'react' + +import { ContribBoundary } from '@/contrib/react/boundary' +import { useContributions } from '@/contrib/react/use-contributions' +import { $routeTiles, closeRouteTile, type RouteTile } from '@/store/route-tiles' + +import { ARTIFACTS_ROUTE, contributedRoutes, MESSAGING_ROUTE, ROUTES_AREA, SKILLS_ROUTE } from '../routes' + +import { paneMirror } from './pane-mirror' + +const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView })) +const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView })) +const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView })) + +// Built-in page views + their pane titles, keyed by route. +const BUILTIN_PAGES: Record<string, { render: () => ReactNode; title: string }> = { + [ARTIFACTS_ROUTE]: { render: () => <ArtifactsView />, title: 'Artifacts' }, + [MESSAGING_ROUTE]: { render: () => <MessagingView />, title: 'Messaging' }, + [SKILLS_ROUTE]: { render: () => <SkillsView />, title: 'Capabilities' } +} + +/** Humanize a route path into a tab title: `/my-atlas` → `My Atlas`. */ +const humanizePath = (path: string): string => + path + .replace(/^\/+/, '') + .split(/[/-]/) + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') || path + +/** Title for a route tile: the built-in name, the contribution's own `title`, + * else a humanized path — never the internal `${source}:${id}` key. */ +function routeTitle(path: string): string { + if (BUILTIN_PAGES[path]) { + return BUILTIN_PAGES[path].title + } + + return contributedRoutes().find(r => r.path === path)?.title ?? humanizePath(path) +} + +function RouteTilePane({ path }: { path: string }) { + const builtin = BUILTIN_PAGES[path] + + // Subscribe so a plugin page tile appears the moment its route registers. + useContributions(ROUTES_AREA) + const contrib = builtin ? null : contributedRoutes().find(r => r.path === path) + + if (builtin) { + return ( + <ContribBoundary id={path}> + <Suspense fallback={null}>{builtin.render()}</Suspense> + </ContribBoundary> + ) + } + + if (contrib) { + return <ContribBoundary id={path}>{contrib.render()}</ContribBoundary> + } + + return ( + <div className="grid h-full place-items-center font-mono text-[11px] text-(--ui-text-quaternary)"> + no page at {path} + </div> + ) +} + +// --------------------------------------------------------------------------- +// Route tile -> pane contribution sync (call once from the app root). +// --------------------------------------------------------------------------- + +/** Keep pane contributions mirroring `$routeTiles`. Call once from the root. */ +export const watchRouteTiles = paneMirror<RouteTile>({ + source: $routeTiles, + key: t => t.path, + prefix: 'route-tile', + dir: t => t.dir, + minWidth: '22rem', + title: routeTitle, + render: path => <RouteTilePane path={path} />, + close: closeRouteTile +}) diff --git a/apps/desktop/src/app/chat/runtime-repository.ts b/apps/desktop/src/app/chat/runtime-repository.ts new file mode 100644 index 000000000000..9dc94d42114a --- /dev/null +++ b/apps/desktop/src/app/chat/runtime-repository.ts @@ -0,0 +1,51 @@ +import { ExportedMessageRepository, type ThreadMessage } from '@assistant-ui/react' +import { useMemo, useRef } from 'react' + +import type { ChatMessage } from '@/lib/chat-messages' +import { coalesceToolOnlyAssistants, createToolMergeCache, toRuntimeMessage } from '@/lib/chat-runtime' + +/** + * ChatMessage[] -> assistant-ui message repository, with a WeakMap identity + * cache so unchanged messages convert once (and a tool-merge cache that folds + * tool-only assistant turns into their neighbour). Shared by the main chat's + * runtime boundary and session tiles — one transcript pipeline, N surfaces. + */ +export function useRuntimeMessageRepository(messages: ChatMessage[]): ExportedMessageRepository { + const cacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>()) + const toolMergeCacheRef = useRef(createToolMergeCache()) + + return useMemo(() => { + const items: { message: ThreadMessage; parentId: string | null }[] = [] + const branchParentByGroup = new Map<string, string | null>() + let visibleParentId: string | null = null + let headId: string | null = null + + for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) { + let parentId = visibleParentId + + if (message.role === 'assistant' && message.branchGroupId) { + if (!branchParentByGroup.has(message.branchGroupId)) { + branchParentByGroup.set(message.branchGroupId, visibleParentId) + } + + parentId = branchParentByGroup.get(message.branchGroupId) ?? null + } + + const cachedMessage = cacheRef.current.get(message) + const runtimeMessage = cachedMessage ?? toRuntimeMessage(message) + + if (!cachedMessage) { + cacheRef.current.set(message, runtimeMessage) + } + + items.push({ message: runtimeMessage, parentId }) + + if (!message.hidden) { + visibleParentId = message.id + headId = message.id + } + } + + return ExportedMessageRepository.fromBranchableArray(items, { headId }) + }, [messages]) +} diff --git a/apps/desktop/src/app/chat/session-drag.ts b/apps/desktop/src/app/chat/session-drag.ts new file mode 100644 index 000000000000..f99bfa47325d --- /dev/null +++ b/apps/desktop/src/app/chat/session-drag.ts @@ -0,0 +1,193 @@ +/** + * Sidebar session drag — the session RESOLVER over the shared pointer drag + * session (pane-shell drag-session.ts). Same machinery as a pane drag + * (threshold, rAF moves, snapshots, Esc-as-top-layer with synchronous + * teardown), session-specific targeting: + * + * - a chat zone's TAB STRIP → stack: open the session as a tab at the + * divider's slot (the strip caret shows it); + * - a chat zone's EDGE band → split: open the session as a tile docked on + * that edge (the zone sheet morphs to the half); + * - a chat zone's CENTER / the composer → link: insert an `@session` chip + * into that surface's composer (ChatDropOverlay owns the visual); + * - anything else (sidebar, terminal, gutters) → deny. + * + * Zones that don't host a chat surface are NOT targets — the overlay never + * lights them, so a release there must not commit either (one truth). + * + * This replaced the native-HTML5 drag + SessionTileDropBridge: riding the + * native DnD layer meant macOS's cancel snap-back animation, a `dragend` + * held hostage until that animation finished, an Esc the page never even + * saw, and window-level armor against react-dnd/dnd-kit. A pointer session + * has none of those failure modes. Native DnD remains only at the true OS + * boundary (Finder file drops). Known trade: a session can no longer be + * dragged into a separate BrowserWindow (native DnD was the only transport + * that crossed windows). + */ + +import type { PointerEvent as ReactPointerEvent } from 'react' + +import { findGroup } from '@/components/pane-shell/tree/model' +import { + type DoubleTapContext, + rectContains, + slotBefore, + snapshotStrips, + snapshotZones, + startDragSession, + type StripSnapshot, + subZonePosition +} from '@/components/pane-shell/tree/renderer/drag-session' +import { + $layoutTree, + $treeDragging, + type DropHint, + revealTreePane, + SESSION_TILE_DRAG +} from '@/components/pane-shell/tree/store' +import type { EngineZone, ZoneRect } from '@/components/pane-shell/tree/zones-engine' +import { openSessionTile, type TileDock } from '@/store/session-states' + +import { requestComposerInsertRefs } from './composer/focus' +import { type SessionDragPayload, sessionInlineRef, sessionLabel } from './composer/inline-refs' + +/** A chat surface's drag-start geometry: the anchor pane id it advertises + * (`data-session-anchor`) and the composer a link drop routes to + * (`data-composer-target`). */ +interface SurfaceSnapshot { + anchor: string + composerTarget: string + rect: ZoneRect +} + +const snapRect = (el: HTMLElement): ZoneRect => { + const r = el.getBoundingClientRect() + + return { left: r.left, top: r.top, right: r.right, bottom: r.bottom } +} + +function snapshotSurfaces(): SurfaceSnapshot[] { + return [...document.querySelectorAll<HTMLElement>('[data-session-anchor]')].map(el => ({ + anchor: el.dataset.sessionAnchor || 'workspace', + composerTarget: el.dataset.composerTarget || 'main', + rect: snapRect(el) + })) +} + +/** A session may land in a zone only if it hosts a chat surface — never the + * sidebar/terminal zones. Returns the pane a stack anchors to. */ +function chatZonePane(groupId: string): null | string { + const tree = $layoutTree.get() + const panes = tree ? (findGroup(tree, groupId)?.panes ?? []) : [] + + return panes.find(p => p === 'workspace' || p.startsWith('session-tile:')) ?? null +} + +/** + * Begin dragging a session — a sidebar row OR a tile's own tab (same drop + * language either way: stack, split, or composer link). Sub-threshold releases + * stay ordinary clicks, so `opts.onTap` (activate the tile) and `opts.double` + * (hide the tab bar) ride the tab's gestures; Esc aborts instantly. A stack/ + * split commits through `openSessionTile`, which OPENS a new tile from a sidebar + * row and MOVES the existing one when its tab is the drag source. + */ +export function startSessionDrag( + payload: SessionDragPayload, + e: ReactPointerEvent<HTMLElement>, + opts?: { double?: DoubleTapContext; onTap?: () => void } +) { + let zones: EngineZone[] = [] + let strips: StripSnapshot[] = [] + let surfaces: SurfaceSnapshot[] = [] + let composers: ZoneRect[] = [] + let zoneHost = new Map<string, null | string>() + + // Commit intent, updated per resolved move (the machinery flushes the final + // move before commit, so these always match the released-at position). + let split: { anchor: string; before?: null | string; pos: TileDock } | null = null + let link: null | string = null + + // The drag SOURCE (sidebar row or tile tab). Captured synchronously — React + // clears `currentTarget` after the pointerdown handler returns, but this runs + // inside it. Dimmed while lifted so the source reads as "picked up" — the + // same in-place feedback pane-tab drags use, replacing the old cursor chip. + const source = e.currentTarget + const restoreOpacity = source?.style.opacity ?? '' + + startDragSession(e, { + double: opts?.double, + ghost: { label: sessionLabel(payload) }, + onTap: opts?.onTap, + + onEngage() { + zones = snapshotZones() + strips = snapshotStrips() + surfaces = snapshotSurfaces() + composers = [...document.querySelectorAll<HTMLElement>('[data-slot="composer-root"]')].map(snapRect) + zoneHost = new Map(zones.map(zone => [zone.id, chatZonePane(zone.id)])) + source?.style.setProperty('opacity', '0.45') + // The same sentinel the zone overlay + chat surfaces key off — the + // whole drop language (sheets, pills, caret, link overlay) lights up. + $treeDragging.set(SESSION_TILE_DRAG) + }, + + onEnd() { + if (source) { + source.style.opacity = restoreOpacity + } + }, + + resolveMove(x, y): DropHint | null { + const zone = zones.find(z => rectContains(z.rect, x, y)) + const host = zone ? zoneHost.get(zone.id) : null + + if (!zone || !host) { + split = null + link = null + + return null + } + + // The zone's TAB STRIP stacks the session at the divider's slot. + const strip = strips.find(s => s.groupId === zone.id && rectContains(s.rect, x, y)) + + if (strip) { + // Exclude the tile's OWN tab from the slots so re-dropping it in its + // home strip reorders cleanly (a no-op for a sidebar-row drag). + const stack = slotBefore(strip.slots, x, `session-tile:${payload.id}`) + split = { anchor: host, before: stack.before, pos: 'center' } + link = null + + return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos: 'center', stack } + } + + // The composer (and everything in it) is always the link/attach drop; + // elsewhere the shared radial targeting decides center vs edge. + const pos = composers.some(rect => rectContains(rect, x, y)) ? 'center' : subZonePosition(zones, zone.id, x, y) + const surface = surfaces.find(s => rectContains(s.rect, x, y)) + + if (pos === 'center') { + split = null + link = surface?.composerTarget ?? 'main' + } else { + split = { anchor: surface?.anchor ?? 'workspace', pos } + link = null + } + + return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos } + }, + + onCommit() { + if (split) { + openSessionTile(payload.id, split.pos, split.anchor, split.before) + // A tile for this session may already exist (openSessionTile is + // idempotent — e.g. persisted from an earlier run): a drop must never + // feel dead, so front/unhide/un-dismiss it either way. + revealTreePane(`session-tile:${payload.id}`) + } else if (link) { + // The "link to chat" drop: an @session chip in that surface's composer. + requestComposerInsertRefs([sessionInlineRef(payload)], { target: link }) + } + } + }) +} diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts new file mode 100644 index 000000000000..f75ff6bf4dac --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -0,0 +1,359 @@ +/** + * Prompt actions for a SESSION TILE — the same verbs the primary chat wires + * (submit incl. slash, cancel, steer, edit, reload, restore, branch-hide + * sync), targeted at the tile's session instead of the active one. State + * writes go through the delegate's `updateSession` (the wiring cache), so + * the cache, the primary view, and every tile mirror stay one truth; view + * concerns (busy pill, transcript) reach the tile via its `$sessionStates` + * slice — never the global `$busy`/`$messages`. + */ + +import type { AppendMessage, ThreadMessage } from '@assistant-ui/react' +import { useCallback, useMemo, useRef } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import type { ClientSessionState } from '@/app/types' +import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' +import { useI18n } from '@/i18n' +import { textPart } from '@/lib/chat-messages' +import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' +import { triggerHaptic } from '@/lib/haptics' +import { clearClarifyRequest } from '@/store/clarify' +import type { ComposerAttachment } from '@/store/composer' +import { resetSessionBackground } from '@/store/composer-status' +import { notifyError } from '@/store/notifications' +import { clearPreviewArtifacts } from '@/store/preview-status' +import { clearAllPrompts } from '@/store/prompts' +import { $connection } from '@/store/session' +import { $sessionStates, sessionTileDelegate } from '@/store/session-states' +import { clearSessionSubagents } from '@/store/subagents' +import { clearSessionTodos } from '@/store/todos' + +import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions' +import { + applyBranchVisibility, + applyReloadOptimistic, + applyRewindOptimistic, + finalizeInterruptedMessages, + planEdit, + planReload, + planRestore, + runRewindSubmit +} from '../session/hooks/use-prompt-actions/rewind' +import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit' +import { type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils' + +import type { ComposerScope } from './composer/scope' + +interface SessionTileActionsArgs { + runtimeId: string + scope: ComposerScope + storedSessionId: string +} + +export function useSessionTileActions({ runtimeId, scope, storedSessionId }: SessionTileActionsArgs) { + const { t } = useI18n() + const copy = t.desktop + const { requestGateway } = useGatewayRequest() + + const runtimeIdRef = useRef(runtimeId) + runtimeIdRef.current = runtimeId + const storedIdRef = useRef(storedSessionId) + storedIdRef.current = storedSessionId + + // Tile busy tracks the SESSION state, never the global $busy — and it must + // read LIVE. A render-time snapshot goes stale (this hook's host doesn't + // re-render on busy edges), and a stale `true` silently blocks every + // subsequent submit ("tile only sends one message"). The setter is a no-op: + // session state owns busy; submit's optimistic writes flow through + // updateSession. + const busyRef = useMemo( + () => + ({ + get current() { + return $sessionStates.get()[runtimeIdRef.current]?.busy ?? false + }, + set current(_value: boolean) { + // Owned by session state. + } + }) as { current: boolean }, + [] + ) + + const update = useCallback( + (updater: (state: ClientSessionState) => ClientSessionState) => + sessionTileDelegate()?.updateSession(runtimeIdRef.current, updater), + [] + ) + + const readState = useCallback(() => $sessionStates.get()[runtimeIdRef.current], []) + const readMessages = useCallback(() => readState()?.messages ?? [], [readState]) + + // Tile-side attachment staging: same upload rules as the primary submit + // (skip synced/pathless, byte-upload files+images), against the tile scope. + const syncAttachmentsForSubmit = useCallback( + async ( + sessionId: string, + attachments: ComposerAttachment[], + options: { updateComposerAttachments?: boolean } = {} + ): Promise<ComposerAttachment[]> => { + const remote = $connection.get()?.mode === 'remote' + const synced: ComposerAttachment[] = [] + + for (const attachment of attachments) { + if (!attachment.path || attachment.attachedSessionId === sessionId) { + synced.push(attachment) + + continue + } + + if (attachment.kind === 'image' || attachment.kind === 'file') { + const next = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }) + + if (options.updateComposerAttachments ?? true) { + scope.attachments.update(next) + } + + synced.push(next) + + continue + } + + synced.push(attachment) + } + + return synced + }, + [requestGateway, scope.attachments] + ) + + // The REAL submit pipeline with tile seams: session always exists, and the + // scope's writers replace the global view/attachment writes. + const submitPromptText = useSubmitPrompt({ + activeSessionId: runtimeId, + activeSessionIdRef: runtimeIdRef, + busyRef, + copy, + createBackendSessionForSend: async () => runtimeIdRef.current, + // A tile IS its session — no route to abandon, so the create-abort guard's + // token is a stable constant (the guard never trips for a tile). + getRouteToken: () => runtimeId, + requestGateway, + selectedStoredSessionIdRef: storedIdRef, + syncAttachmentsForSubmit, + updateSessionState: (sessionId, updater) => sessionTileDelegate()!.updateSession(sessionId, updater), + scope: { + clearAttachments: scope.attachments.clear, + readAttachments: () => scope.attachments.$attachments.get(), + // Busy/messages flow through updateSession -> the tile's state slice; + // the primary view atoms must never see a tile turn. + setAwaitingResponse: () => undefined, + setBusy: () => undefined, + setMessages: () => undefined + } + }) + + const submitText = useCallback( + async (rawText: string, options?: SubmitTextOptions) => { + const visibleText = rawText.trim() + const attachments = options?.attachments ?? scope.attachments.$attachments.get() + + if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) { + triggerHaptic('selection') + await sessionTileDelegate()?.executeSlash(visibleText, runtimeIdRef.current) + + return true + } + + return await submitPromptText(rawText, options) + }, + [scope.attachments.$attachments, submitPromptText] + ) + + const appendSystemNote = useCallback( + (text: string) => { + update(state => ({ + ...state, + messages: [ + ...state.messages, + { id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, role: 'system', parts: [textPart(text)] } + ] + })) + }, + [update] + ) + + const cancelRun = useCallback(async () => { + const sessionId = runtimeIdRef.current + + update(state => ({ + ...state, + messages: finalizeInterruptedMessages(state.messages, state.streamId), + busy: false, + awaitingResponse: false, + streamId: null, + pendingBranchGroup: null, + needsInput: false, + interrupted: true + })) + + clearSessionTodos(sessionId) + clearSessionSubagents(sessionId) + resetSessionBackground(sessionId) + clearAllPrompts(sessionId) + clearClarifyRequest(undefined, sessionId) + + try { + await requestGateway('session.interrupt', { session_id: sessionId }) + } catch (err) { + notifyError(err, copy.stopFailed) + } + }, [copy.stopFailed, requestGateway, update]) + + const steerPrompt = useCallback( + async (rawText: string): Promise<boolean> => { + const text = rawText.trim() + + if (!text) { + return false + } + + try { + const result = await requestGateway<{ status?: string }>('session.steer', { + session_id: runtimeIdRef.current, + text + }) + + if (result?.status === 'queued') { + triggerHaptic('submit') + appendSystemNote(`steer:${text}`) + + return true + } + } catch { + // Swallow — the caller queues the text so nothing is lost. + } + + return false + }, + [appendSystemNote, requestGateway] + ) + + // Rewind primitive (interrupt-first for live turns, busy-retry) — shared with + // the primary chat so the two can't diverge. + const submitRewind = useCallback( + (text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => + runRewindSubmit(requestGateway, runtimeIdRef.current, text, truncateOrdinal, interruptFirst), + [requestGateway] + ) + + const reloadFromMessage = useCallback( + async (parentId: string | null) => { + const state = readState() + + if (!state || state.busy) { + return + } + + const plan = planReload(state.messages, parentId) + + if (!plan) { + return + } + + update(current => applyReloadOptimistic(current, plan)) + + try { + await requestGateway( + 'prompt.submit', + { session_id: runtimeIdRef.current, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal }, + PROMPT_SUBMIT_REQUEST_TIMEOUT_MS + ) + } catch (err) { + update(current => ({ ...current, busy: false, awaitingResponse: false })) + notifyError(err, copy.regenerateFailed) + } + }, + [copy.regenerateFailed, readState, requestGateway, update] + ) + + const restoreToMessage = useCallback( + async (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => { + const sessionId = runtimeIdRef.current + const messages = readMessages() + const plan = planRestore(messages, messageId, target) + + clearSessionTodos(sessionId) + resetSessionBackground(sessionId) + clearPreviewArtifacts(sessionId) + + const wasBusy = readState()?.busy ?? false + + update(state => applyRewindOptimistic(state, plan.sourceIndex)) + + try { + await submitRewind(plan.text, plan.truncateOrdinal, wasBusy) + } catch (err) { + update(state => ({ ...state, busy: false, awaitingResponse: false, messages })) + throw err + } + }, + [readMessages, readState, submitRewind, update] + ) + + const editMessage = useCallback( + async (edited: AppendMessage) => { + const messages = readMessages() + const plan = planEdit(messages, edited) + + if (!plan) { + return + } + + const sessionId = runtimeIdRef.current + + clearSessionTodos(sessionId) + resetSessionBackground(sessionId) + clearPreviewArtifacts(sessionId) + + const wasBusy = readState()?.busy ?? false + + update(state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage)) + + try { + await submitRewind(plan.text, plan.truncateOrdinal, wasBusy) + } catch (err) { + update(state => ({ ...state, busy: false, awaitingResponse: false, messages })) + notifyError(err, copy.editFailed) + } + }, + [copy.editFailed, readMessages, readState, submitRewind, update] + ) + + // Branch-visibility sync (assistant-ui hides non-active branches). + const handleThreadMessagesChange = useCallback( + (nextMessages: readonly ThreadMessage[]) => update(state => applyBranchVisibility(state, nextMessages)), + [update] + ) + + const dismissError = useCallback( + (messageId: string) => { + update(state => ({ ...state, messages: state.messages.filter(m => m.id !== messageId) })) + }, + [update] + ) + + return useMemo( + () => ({ + cancelRun, + dismissError, + editMessage, + handleThreadMessagesChange, + reloadFromMessage, + restoreToMessage, + steerPrompt, + submitText + }), + [cancelRun, dismissError, editMessage, handleThreadMessagesChange, reloadFromMessage, restoreToMessage, steerPrompt, submitText] + ) +} diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx new file mode 100644 index 000000000000..04ddaf1cc630 --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -0,0 +1,436 @@ +/** + * SESSION TILES — a stored session rendered as a layout-tree pane BESIDE the + * main thread (multi-session tiling). A tile IS the real chat surface: the + * same ChatView/ChatBar/Thread tree the primary session renders, mounted + * under a tile `SessionView` (its session's slice of `$sessionStates`) and a + * tile `ComposerScope` (own attachment chips, own focus-bus key). Actions + * (submit/slash/steer/edit/reload/restore/stop) come from + * `useSessionTileActions`, all writing through the wiring cache. + * + * Lifecycle: `openSessionTile(storedId)` -> `watchSessionTiles` registers a + * pane contribution docked right of the main zone -> tree adoption lands it + * -> the pane mounts and asks the delegate for a live runtime id. Closing + * the pane (tab Close) removes the tile + its zone; tiles persist across + * restarts and re-resume on boot. + */ + +import { useStore } from '@nanostores/react' +import { atom, computed } from 'nanostores' +import { useEffect, useMemo, useRef } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils' +import { formatRefValue } from '@/components/assistant-ui/directive-text' +import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status' +import { findGroupOfPane } from '@/components/pane-shell/tree/model' +import { $layoutTree, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store' +import { Button } from '@/components/ui/button' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' +import { transcribeAudio } from '@/hermes' +import { useI18n } from '@/i18n' +import type { ChatMessage } from '@/lib/chat-messages' +import { sessionTitle } from '@/lib/chat-runtime' +import { createComposerAttachmentScope } from '@/store/composer' +import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout' +import { sessionAwaitingInput } from '@/store/prompts' +import { + $gatewayState, + $selectedStoredSessionId, + $sessions, + sessionMatchesStoredId, + sessionPinId +} from '@/store/session' +import { + $sessionStates, + $sessionTiles, + closeSessionTile, + discardSessionTile, + patchSessionTile, + type SessionTile, + sessionTileDelegate +} from '@/store/session-states' + +import type { SessionDragPayload } from './composer/inline-refs' +import { type ComposerScope, ComposerScopeProvider } from './composer/scope' +import { useComposerActions } from './hooks/use-composer-actions' +import { paneMirror } from './pane-mirror' +import { startSessionDrag } from './session-drag' +import { useSessionTileActions } from './session-tile-actions' +import { type SessionView, SessionViewProvider } from './session-view' +import { SessionContextMenu } from './sidebar/session-actions-menu' +import { lastVisibleMessageIsUser } from './thread-loading' + +import { ChatView } from '.' + +const NO_MESSAGES: ChatMessage[] = [] + +/** The tile's SessionView: the same atom shape the primary chat renders + * from, computed from this session's slice of `$sessionStates`. */ +function buildTileView(storedSessionId: string): SessionView { + const $runtimeId = computed( + $sessionTiles, + tiles => tiles.find(t => t.storedSessionId === storedSessionId)?.runtimeId ?? null + ) + + const $state = computed([$runtimeId, $sessionStates], (runtimeId, states) => + runtimeId ? states[runtimeId] : undefined + ) + + const $messages = computed($state, state => state?.messages ?? NO_MESSAGES) + + return { + kind: 'tile', + $awaitingResponse: computed($state, state => Boolean(state?.awaitingResponse)), + $busy: computed($state, state => Boolean(state?.busy)), + $cwd: computed($state, state => state?.cwd ?? ''), + $lastVisibleIsUser: computed($messages, lastVisibleMessageIsUser), + $messages, + $messagesEmpty: computed($messages, messages => messages.length === 0), + $model: computed($state, state => state?.model ?? ''), + $provider: computed($state, state => state?.provider ?? ''), + $runtimeId, + // Constant for the tile's lifetime — a plain atom, not a computed. + $storedId: atom(storedSessionId) + } +} + +function TileChat({ + runtimeId, + storedSessionId, + view +}: { + runtimeId: string + storedSessionId: string + view: SessionView +}) { + const { gatewayRef, requestGateway } = useGatewayRequest() + const cwd = useStore(view.$cwd) + + // One attachment set + focus key per tile, stable for the tile's lifetime. + const attachments = useRef(createComposerAttachmentScope()).current + + const scope = useMemo<ComposerScope>( + () => ({ + $awaitingInput: sessionAwaitingInput(runtimeId), + attachments, + popoutAllowed: false, + readMessages: () => view.$messages.get(), + target: `tile:${storedSessionId}` + }), + [attachments, runtimeId, storedSessionId, view.$messages] + ) + + const actions = useSessionTileActions({ runtimeId, scope, storedSessionId }) + + // The same attach/pick/paste/drop pipeline the primary composer uses, + // pointed at this tile's chips + session. + const composer = useComposerActions({ + activeSessionId: runtimeId, + currentCwd: cwd, + requestGateway, + scope: { add: attachments.add, remove: attachments.remove, target: scope.target } + }) + + return ( + <SessionViewProvider value={view}> + <ComposerScopeProvider value={scope}> + <ChatView + gateway={gatewayRef.current} + onAddContextRef={composer.addContextRefAttachment} + onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} + onAttachDroppedItems={composer.attachDroppedItems} + onAttachImageBlob={composer.attachImageBlob} + onBranchInNewChat={() => undefined} + onCancel={actions.cancelRun} + onDeleteSelectedSession={() => undefined} + onDismissError={actions.dismissError} + onEdit={actions.editMessage} + onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)} + onPickFiles={() => void composer.pickContextPaths('file')} + onPickFolders={() => void composer.pickContextPaths('folder')} + onPickImages={() => void composer.pickImages()} + onReload={actions.reloadFromMessage} + onRemoveAttachment={id => void composer.removeAttachment(id)} + onRestoreToMessage={actions.restoreToMessage} + onRetryResume={() => patchSessionTile(storedSessionId, { error: undefined })} + onSteer={actions.steerPrompt} + onSubmit={actions.submitText} + onThreadMessagesChange={actions.handleThreadMessagesChange} + onToggleSelectedPin={() => undefined} + onTranscribeAudio={async audio => (await transcribeAudio(await blobToDataUrl(audio), audio.type)).transcript} + /> + </ComposerScopeProvider> + </SessionViewProvider> + ) +} + +export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) { + const tiles = useStore($sessionTiles) + const tile = tiles.find(t => t.storedSessionId === storedSessionId) + const runtimeId = tile?.runtimeId ?? null + const gatewayOpen = useStore($gatewayState) === 'open' + const resumingRef = useRef(false) + const view = useMemo(() => buildTileView(storedSessionId), [storedSessionId]) + + // Same gating as the primary's route resume (use-route-resume): never fire + // session.resume before the gateway is OPEN. Persisted tiles mount at boot + // while it's still connecting — an ungated resume rejected there and + // latched every restored tile into the error card. + useEffect(() => { + if (!gatewayOpen || runtimeId || tile?.error || resumingRef.current) { + return + } + + const delegate = sessionTileDelegate() + + if (!delegate) { + return + } + + resumingRef.current = true + + delegate + .resumeTile(storedSessionId) + .then(id => patchSessionTile(storedSessionId, { error: undefined, runtimeId: id })) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + + // A gone session (404 / "Session not found") is terminal — a stale or + // cross-profile persisted tile. Discard it instead of latching an error + // that re-retries on every reconnect (the "Session not found" spam). + if (/session not found|\b404\b/i.test(message)) { + discardSessionTile(storedSessionId) + } else { + patchSessionTile(storedSessionId, { error: message }) + } + }) + .finally(() => { + resumingRef.current = false + }) + }, [gatewayOpen, runtimeId, storedSessionId, tile?.error]) + + // The gateway (re)opening invalidates any latched error — it likely came + // from a not-yet-open gateway or the previous connection. Clearing it + // retriggers the resume effect: one bounded auto-retry per (re)connect, + // mirroring the primary path's became-open resync. + useEffect(() => { + if (gatewayOpen && tile?.error) { + patchSessionTile(storedSessionId, { error: undefined }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gatewayOpen, storedSessionId]) + + if (tile?.error) { + return ( + <div className="grid h-full place-items-center p-4"> + <div className="max-w-[24rem] space-y-2 text-center font-mono text-[11px]"> + <div className="text-(--ui-danger,#f87171)">Couldn't open this session</div> + <div className="break-words text-(--ui-text-quaternary)">{tile.error}</div> + <Button onClick={() => patchSessionTile(storedSessionId, { error: undefined })} size="sm" variant="outline"> + Retry + </Button> + </div> + </div> + ) + } + + if (!runtimeId) { + // The SAME session loader the primary thread shows (Thread's + // loading === 'session' branch) — one loading language everywhere. + return ( + <div className="relative h-full"> + <CenteredThreadSpinner /> + </div> + ) + } + + return <TileChat runtimeId={runtimeId} storedSessionId={storedSessionId} view={view} /> +} + +// --------------------------------------------------------------------------- +// Tile -> pane contribution sync (call once from the app root). +// --------------------------------------------------------------------------- + +function tileTitle(storedSessionId: string): string { + const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) + + return stored ? sessionTitle(stored) : 'Session' +} + +/** The `@session` link payload for a tile tab drag — id + owning profile + title. */ +function tileDragPayload(storedSessionId: string): SessionDragPayload { + const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) + + return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) } +} + +// --------------------------------------------------------------------------- +// Close confirmation — a BUSY tab (streaming, or blocked on clarify/approval +// input) doesn't close silently. +// --------------------------------------------------------------------------- + +/** Stored id awaiting close confirmation (null = no dialog). */ +const $confirmCloseTile = atom<null | string>(null) + +/** The tile closer, gated: a quiet session closes immediately; a busy or + * input-blocked one asks first. One state read — the tile's runtime slice. */ +export function requestCloseSessionTile(storedSessionId: string): void { + const runtimeId = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId)?.runtimeId + const state = runtimeId ? $sessionStates.get()[runtimeId] : undefined + + if (state?.busy || state?.awaitingResponse || state?.needsInput) { + $confirmCloseTile.set(storedSessionId) + } else { + closeSessionTile(storedSessionId) + } +} + +/** Mounted once at the shell root: the "Close running tab?" confirmation. */ +export function SessionTileCloseConfirm() { + const { t } = useI18n() + const storedSessionId = useStore($confirmCloseTile) + + return ( + <ConfirmDialog + confirmLabel={t.zones.closeRunningConfirm} + description={t.zones.closeRunningBody} + destructive + onClose={() => $confirmCloseTile.set(null)} + onConfirm={() => { + if (storedSessionId) { + closeSessionTile(storedSessionId) + } + }} + open={storedSessionId !== null} + title={t.zones.closeRunningTitle} + /> + ) +} + +/** Layout reset → every session tile collapses into the MAIN zone as a tab + * after the workspace (the primary session stays the first tab), the "smart" + * reset: N scattered tiles become one tab bar over the chat instead of + * re-docking to their old edges. + * + * Runs BEFORE generic adoption (see registerLayoutResetHandler) — the tiles + * aren't in the fresh tree yet, so each `moveTreePane` ADDS the tile into the + * workspace group as a tab (append). The main group id is re-read each pass + * because appending returns a new tree. */ +export function stackSessionTilesIntoMain(): void { + for (const tile of $sessionTiles.get()) { + const tree = $layoutTree.get() + const mainGroup = tree ? findGroupOfPane(tree, 'workspace')?.id : null + + if (mainGroup) { + moveTreePane(`session-tile:${tile.storedSessionId}`, { groupId: mainGroup, pos: 'center' }) + } + } +} + +/** A session TAB's context menu: the full session verb set (pin, copy id, new + * window, branch, rename, archive, delete) — the SAME menu a sidebar row + * gets, targeted through the tile delegate (whose verbs are generic over + * stored ids, primary included). The wrapper stops the contextmenu from also + * opening the zone strip's menu. Shared by tile tabs AND the main tab. */ +export function SessionTabMenu({ + children, + onClose, + onHideTabBar, + storedSessionId, + tabPaneId +}: { + children: React.ReactElement + /** Close this tab (tiles; the main tab passes nothing). */ + onClose?: () => void + /** Hide the zone's tab bar (main tab only — the sticky bar's off switch). */ + onHideTabBar?: () => void + storedSessionId: string + /** Layout-tree pane id — powers the Close-others/right/all verbs. */ + tabPaneId: string +}) { + const sessions = useStore($sessions) + const pinnedSessionIds = useStore($pinnedSessionIds) + const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId)) + const pinId = stored ? sessionPinId(stored) : storedSessionId + const pinned = pinnedSessionIds.includes(pinId) + + return ( + <span className="contents" onContextMenu={event => event.stopPropagation()}> + <SessionContextMenu + onArchive={() => void sessionTileDelegate()?.archiveSession(storedSessionId)} + onBranch={() => void sessionTileDelegate()?.branchSession(storedSessionId)} + onClose={onClose} + onDelete={() => void sessionTileDelegate()?.deleteSession(storedSessionId)} + onHideTabBar={onHideTabBar} + onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))} + pinned={pinned} + profile={stored?.profile} + sessionId={storedSessionId} + surface="tab" + tabPaneId={tabPaneId} + title={tileTitle(storedSessionId)} + > + {children} + </SessionContextMenu> + </span> + ) +} + +/** The MAIN tab's menu: the same session verbs targeting the primary's loaded + * session, plus the bar's off switch (the bar sticky-shows once a tab is + * ever gained; this is the explicit way back). A fresh draft has no session — + * no menu. */ +export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) { + const selected = useStore($selectedStoredSessionId) + + const hideTabBar = () => { + const tree = $layoutTree.get() + const group = tree ? findGroupOfPane(tree, 'workspace') : null + + if (group) { + setTreeGroupHeaderHidden(group.id, true) + } + } + + if (!selected) { + return children + } + + return ( + <SessionTabMenu onHideTabBar={hideTabBar} storedSessionId={selected} tabPaneId="workspace"> + {children} + </SessionTabMenu> + ) +} + +/** Keep pane contributions mirroring `$sessionTiles` (+ titles from + * `$sessions`). Tiles dock against main on the chosen edge, flex width. */ +export const watchSessionTiles = paneMirror<SessionTile>({ + source: $sessionTiles, + also: [$sessions], + key: t => t.storedSessionId, + prefix: 'session-tile', + dir: t => t.dir, + anchor: t => t.anchor, + before: t => t.before, + minWidth: '20rem', + title: tileTitle, + render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />, + tabWrap: (storedSessionId, tab) => ( + <SessionTabMenu + onClose={() => requestCloseSessionTile(storedSessionId)} + storedSessionId={storedSessionId} + tabPaneId={`session-tile:${storedSessionId}`} + > + {tab} + </SessionTabMenu> + ), + // A tile's tab drags like a sidebar row — stack / split / drop-to-link — with + // its tap (activate) + double-tap (hide bar) preserved. Always takes the drag. + tabDrag: (storedSessionId, event, onTap, double) => { + startSessionDrag(tileDragPayload(storedSessionId), event, { double, onTap }) + + return true + }, + close: requestCloseSessionTile +}) diff --git a/apps/desktop/src/app/chat/session-view.tsx b/apps/desktop/src/app/chat/session-view.tsx new file mode 100644 index 000000000000..af72d0ceb41e --- /dev/null +++ b/apps/desktop/src/app/chat/session-view.tsx @@ -0,0 +1,61 @@ +import type { ReadableAtom } from 'nanostores' +import { createContext, useContext } from 'react' + +import type { ChatMessage } from '@/lib/chat-messages' +import { + $activeSessionId, + $awaitingResponse, + $busy, + $currentCwd, + $currentModel, + $currentProvider, + $lastVisibleMessageIsUser, + $messages, + $messagesEmpty, + $selectedStoredSessionId +} from '@/store/session' + +/** + * SESSION VIEW — the store surface a ChatView renders from. The PRIMARY view + * is the app's classic global atoms (route-driven active session, untouched + * fast path). A session TILE provides the same shape computed from its + * session's slice of `$sessionStates`, so the identical ChatView tree renders + * either — one chat surface, N sessions on screen. + * + * Everything is atoms (not values) so subscription granularity survives: + * ChatView subscribes only to the coarse edges; `$messages` stays boundary- + * only exactly like the primary view's perf contract. + */ +export interface SessionView { + kind: 'primary' | 'tile' + $runtimeId: ReadableAtom<string | null> + $storedId: ReadableAtom<string | null> + $messages: ReadableAtom<ChatMessage[]> + $busy: ReadableAtom<boolean> + $awaitingResponse: ReadableAtom<boolean> + $messagesEmpty: ReadableAtom<boolean> + $lastVisibleIsUser: ReadableAtom<boolean> + $cwd: ReadableAtom<string> + $model: ReadableAtom<string> + $provider: ReadableAtom<string> +} + +export const PRIMARY_SESSION_VIEW: SessionView = { + kind: 'primary', + $awaitingResponse, + $busy, + $cwd: $currentCwd, + $lastVisibleIsUser: $lastVisibleMessageIsUser, + $messages, + $messagesEmpty, + $model: $currentModel, + $provider: $currentProvider, + $runtimeId: $activeSessionId, + $storedId: $selectedStoredSessionId +} + +const SessionViewContext = createContext<SessionView>(PRIMARY_SESSION_VIEW) + +export const SessionViewProvider = SessionViewContext.Provider + +export const useSessionView = (): SessionView => useContext(SessionViewContext) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 416483dde427..9a3fbe3a2748 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -3,10 +3,12 @@ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable' import { useStore } from '@nanostores/react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useLocation } from 'react-router-dom' import { PlatformAvatar } from '@/app/messaging/platform-icon' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { KbdGroup } from '@/components/ui/kbd' import { SearchField } from '@/components/ui/search-field' @@ -19,6 +21,7 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' +import { useContributions } from '@/contrib/react/use-contributions' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { useI18n } from '@/i18n' import { comboTokens } from '@/lib/keybinds/combo' @@ -34,8 +37,6 @@ import { $sidebarAgentsGrouped, $sidebarCronOpen, $sidebarMessagingOpenIds, - $sidebarOpen, - $sidebarOverlayMounted, $sidebarPinsOpen, $sidebarProjectOrderIds, $sidebarRecentsOpen, @@ -78,6 +79,7 @@ import { refreshWorktrees, scanAndRecordRepos } from '@/store/projects' +import { openRouteTile } from '@/store/route-tiles' import { $cronSessions, $currentCwd, @@ -85,7 +87,6 @@ import { $messagingPlatformTotals, $messagingSessions, $messagingTruncated, - $selectedStoredSessionId, $sessionProfileTotals, $sessions, $sessionsLoading, @@ -94,8 +95,16 @@ import { sessionPinId, setCurrentCwd } from '@/store/session' +import { $focusedStoredSessionId, type SplitDir } from '@/store/session-states' -import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes' +import { + type AppView, + ARTIFACTS_ROUTE, + MESSAGING_ROUTE, + SIDEBAR_NAV_AREA, + type SidebarNavContribution, + SKILLS_ROUTE +} from '../../routes' import type { SidebarNavItem } from '../../types' import { countLabel } from './chrome' @@ -121,6 +130,7 @@ import { } from './projects' import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states' import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section' +import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu' // Non-session groups (messaging platforms) stay compact: show a few rows up // front, reveal more in larger steps on demand. Keeps a busy platform from @@ -209,6 +219,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> { onArchiveSession: (sessionId: string) => void onBranchSession: (sessionId: string) => void onNewSessionInWorkspace: (path: null | string) => void + /** Create a brand-new session and open it as a tile on `dir`. */ + onNewSessionSplit: (dir: SplitDir) => void onManageCronJob: (jobId: string) => void onTriggerCronJob: (jobId: string) => void } @@ -224,22 +236,49 @@ export function ChatSidebar({ onArchiveSession, onBranchSession, onNewSessionInWorkspace, + onNewSessionSplit, onManageCronJob, onTriggerCronJob }: ChatSidebarProps) { const { t } = useI18n() const s = t.sidebar - const sidebarOpen = useStore($sidebarOpen) - // Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail. - const overlayMounted = useStore($sidebarOverlayMounted) - const contentVisible = sidebarOpen || overlayMounted + const { pathname } = useLocation() + // Contributed nav rows (plugins pairing a page with a sidebar entry) render + // below the built-ins with the same chrome; active = at their route. + const navContributions = useContributions(SIDEBAR_NAV_AREA) + + const contributedNav = useMemo<SidebarNavItem[]>( + () => + navContributions.flatMap(c => { + const data = c.data as Partial<SidebarNavContribution> | undefined + + if (!data?.path?.startsWith('/') || !data.label) { + return [] + } + + const codicon = data.codicon || 'plug' + + return [ + { + id: c.id, + label: data.label, + icon: (props: { className?: string }) => <Codicon name={codicon} {...props} />, + route: data.path + } + ] + }), + [navContributions] + ) + const panesFlipped = useStore($panesFlipped) const agentsGrouped = useStore($sidebarAgentsGrouped) const pinnedSessionIds = useStore($pinnedSessionIds) const pinsOpen = useStore($sidebarPinsOpen) const agentsOpen = useStore($sidebarRecentsOpen) const cronOpen = useStore($sidebarCronOpen) - const selectedSessionId = useStore($selectedStoredSessionId) + // The sidebar highlight tracks the FOCUSED session — the interacted tile's + // tab, else the main selection — so it stays 1:1 with whatever tab is active. + const selectedSessionId = useStore($focusedStoredSessionId) const sessions = useStore($sessions) const cronSessions = useStore($cronSessions) const cronJobs = useStore($cronJobs) @@ -1031,15 +1070,12 @@ export function ChatSidebar({ return ( <Sidebar className={cn( + // Visibility is the layout tree's job (a hidden zone is display:none; + // the narrow overlay renders the live instance) — the sidebar always + // paints itself fully. 'relative h-full min-w-0 overflow-hidden border-t-0 border-b-0 text-foreground transition-none', panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0', - sidebarOpen - ? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100' - : 'pointer-events-none border-transparent bg-transparent opacity-0', - // While floated by PaneShell's hover-reveal, force visible + interactive - // — on hover (group-hover/reveal) or when keyboard-pinned (data-forced). - 'in-data-[pane-hover-reveal=open]:pointer-events-auto in-data-[pane-hover-reveal=open]:border-(--sidebar-edge-border) in-data-[pane-hover-reveal=open]:bg-(--ui-sidebar-surface-background) in-data-[pane-hover-reveal=open]:opacity-100', - 'group-hover/reveal:pointer-events-auto group-hover/reveal:border-(--sidebar-edge-border) group-hover/reveal:bg-(--ui-sidebar-surface-background) group-hover/reveal:opacity-100' + 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100' )} collapsible="none" > @@ -1047,62 +1083,85 @@ export function ChatSidebar({ <SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]"> <SidebarGroupContent> <SidebarMenu className="gap-px"> - {SIDEBAR_NAV.map(item => { + {[...SIDEBAR_NAV, ...contributedNav].map(item => { const isInteractive = Boolean(item.action) || Boolean(item.route) const active = (item.id === 'skills' && currentView === 'skills') || (item.id === 'messaging' && currentView === 'messaging') || - (item.id === 'artifacts' && currentView === 'artifacts') + (item.id === 'artifacts' && currentView === 'artifacts') || + // Contributed rows light up at their own route. + (Boolean(item.route) && pathname === item.route) const isNewSession = item.id === 'new-session' + const button = ( + <SidebarMenuButton + aria-disabled={!isInteractive} + className={cn( + // no-drag: these rows sit directly under the titlebar's + // [-webkit-app-region:drag] strips (app-shell.tsx), with only + // 6px of clearance. Drag regions win hit-testing over DOM + // (pointer-events can't override), and on Linux/WSLg the + // resolved region has been observed to swallow clicks on the + // top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in + // thread.tsx. + 'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none', + active && + 'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!', + !isInteractive && + 'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit' + )} + onClick={() => { + // A plain new session lands in whatever profile the live + // gateway is on (= the active switcher context). null → + // no swap. The switcher header is the single place to + // change which profile that is. + if (isNewSession) { + $newChatProfile.set(null) + } + + onNavigate(item) + }} + tooltip={s.nav[item.id] ?? item.label} + type="button" + > + <item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" /> + <span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span> + {isNewSession && ( + <KbdGroup + className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')} + keys={[...NEW_SESSION_KBD]} + size="sm" + /> + )} + </SidebarMenuButton> + ) + + // New session + route-backed pages can open in a split — + // right-click for the directional "Open in split" submenu. return ( <SidebarMenuItem key={item.id}> - <SidebarMenuButton - aria-disabled={!isInteractive} - className={cn( - // no-drag: these rows sit directly under the titlebar's - // [-webkit-app-region:drag] strips (app-shell.tsx), with only - // 6px of clearance. Drag regions win hit-testing over DOM - // (pointer-events can't override), and on Linux/WSLg the - // resolved region has been observed to swallow clicks on the - // top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in - // thread.tsx. - 'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none', - active && - 'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!', - !isInteractive && - 'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit' - )} - onClick={() => { - // A plain new session lands in whatever profile the live - // gateway is on (= the active switcher context). null → - // no swap. The switcher header is the single place to - // change which profile that is. - if (isNewSession) { - $newChatProfile.set(null) - } - - onNavigate(item) - }} - tooltip={s.nav[item.id] ?? item.label} - type="button" - > - <item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" /> - {contentVisible && ( - <> - <span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span> - {isNewSession && ( - <KbdGroup - className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')} - keys={[...NEW_SESSION_KBD]} - size="sm" - /> - )} - </> - )} - </SidebarMenuButton> + {isNewSession || item.route ? ( + <ContextMenu> + <ContextMenuTrigger asChild>{button}</ContextMenuTrigger> + <ContextMenuContent aria-label={s.nav[item.id] ?? item.label}> + <SplitSubmenu + kit={CONTEXT_SPLIT_KIT} + label={s.row.openInSplit} + onSplit={dir => { + if (isNewSession) { + onNewSessionSplit(dir) + } else if (item.route) { + openRouteTile(item.route, dir) + } + }} + /> + </ContextMenuContent> + </ContextMenu> + ) : ( + button + )} </SidebarMenuItem> ) })} @@ -1110,7 +1169,7 @@ export function ChatSidebar({ </SidebarGroupContent> </SidebarGroup> - {contentVisible && showSessionSections && ( + {showSessionSections && ( <div className="shrink-0 px-2 pb-1 pt-1"> <SearchField aria-label={s.searchAria} @@ -1122,7 +1181,7 @@ export function ChatSidebar({ </div> )} - {contentVisible && showSessionSections && ( + {showSessionSections && ( <div className={cn('flex min-h-0 flex-1 flex-col pb-1.75', SCROLL_Y)}> {trimmedQuery && ( <SidebarSessionsSection @@ -1392,13 +1451,11 @@ export function ChatSidebar({ </div> )} - {contentVisible && !showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />} + {!showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />} - {contentVisible && ( - <div className="shrink-0 px-0.5 pb-1 pt-0.5"> - <ProfileRail /> - </div> - )} + <div className="shrink-0 px-0.5 pb-1 pt-0.5"> + <ProfileRail /> + </div> </SidebarContent> <ProjectDialog /> </Sidebar> diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index c3016ec67c83..f3cb814e694a 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -35,6 +35,12 @@ import { getProfileSoul, updateProfileSoul } from '@/hermes' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color' +import { + REORDER_DRAG_TRANSITION_CSS, + REORDER_RAIL_TRANSITION, + reorderCommitHaptic, + reorderStepHaptic +} from '@/lib/reorder' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { @@ -67,12 +73,11 @@ const RAIL_GAP = 4 // px — matches gap-1 between squares. // select. Drag-reorder and long-press-recolor live only on the squares path. const PROFILE_DROPDOWN_THRESHOLD = 13 -// easeOutBack — a little overshoot so squares spring into their new slot rather -// than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square -// glides between snapped cells on the snappier DRAG_TRANSITION. -const SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)' -const RAIL_TRANSITION = { duration: 300, easing: SPRING } -const DRAG_TRANSITION = `transform 200ms ${SPRING}` +// Neighbors reflow on RAIL_TRANSITION; the dragged square glides between +// snapped cells on the snappier DRAG_TRANSITION. Both come from the SHARED +// reorder primitive (lib/reorder.ts) so every reorder strip feels identical. +const RAIL_TRANSITION = REORDER_RAIL_TRANSITION +const DRAG_TRANSITION = REORDER_DRAG_TRANSITION_CSS // The rail is a single horizontal strip of fixed cells. Pin drags to the x-axis // (no cross-axis scrollbar), snap to whole cells so a square steps slot-to-slot @@ -174,7 +179,7 @@ export function ProfileRail() { if (id && id !== lastOverRef.current) { lastOverRef.current = id - triggerHaptic('selection') + reorderStepHaptic() } } @@ -191,7 +196,7 @@ export function ProfileRail() { if (from >= 0 && to >= 0) { setProfileOrder(arrayMove(ids, from, to)) - triggerHaptic('success') + reorderCommitHaptic() } } diff --git a/apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx b/apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx new file mode 100644 index 000000000000..c3870be01357 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx @@ -0,0 +1,160 @@ +import { useStore } from '@nanostores/react' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import type { HermesGitBaseBranch } from '@/global' +import { useI18n } from '@/i18n' +import { $repoStatus } from '@/store/coding-status' +import { listBaseBranches } from '@/store/projects' + +// Filterable combobox for picking the base branch of a new worktree. Lists +// local + remote-tracking branches, defaults to the default branch +// (origin/HEAD, or local main/master when no remote). The current session's +// branch is sorted to the top so it's one click away. The parent owns the +// selected value via `value` / `onValueChange`. +export function BaseBranchPicker({ + disabled, + repoPath, + onValueChange, + value +}: { + disabled?: boolean + repoPath: string + onValueChange: (value: string) => void + value: string +}) { + const { t } = useI18n() + const p = t.sidebar.projects + const repoStatus = useStore($repoStatus) + const [branches, setBranches] = useState<HermesGitBaseBranch[]>([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + + const currentBranch = repoStatus?.detached ? null : (repoStatus?.branch ?? null) + + const load = useCallback(async () => { + if (!repoPath) { + return + } + + setLoading(true) + + try { + const list = await listBaseBranches(repoPath) + setBranches(list) + + // Default to the remote default (origin/HEAD). Fall back to the local + // default branch (main/master) when no remote exists. The value is + // always a concrete branch — never undefined. + const defaultBranch = list.find(b => b.isDefault) + + if (defaultBranch) { + onValueChange(defaultBranch.name) + } else { + onValueChange(list[0]?.name ?? '') + } + } catch { + setBranches([]) + } finally { + setLoading(false) + } + }, [repoPath, onValueChange]) + + // Load on mount so the default branch fills in before the user opens the + // popover — otherwise the button reads "branch off " with nothing after it. + useEffect(() => { + if (branches.length === 0 && !loading) { + void load() + } + }, [branches.length, loading, load]) + + // Pin the current session's branch to the top, keep the rest in git's + // most-recently-committed order. + const sorted = useMemo(() => { + if (!currentBranch) { + return branches + } + + const idx = branches.findIndex(b => b.name === currentBranch) + + if (idx <= 0) { + return branches + } + + return [branches[idx], ...branches.slice(0, idx), ...branches.slice(idx + 1)] + }, [branches, currentBranch]) + + // The i18n function returns { before, after } so the branch name can be + // wrapped in its own styled (underlined) span — works for any word order. + const parts = p.branchOff() + + return ( + <div className="space-y-1.5"> + <Popover + onOpenChange={next => { + if (next && branches.length === 0 && !loading) { + void load() + } + + setOpen(next) + }} + open={open} + > + <PopoverTrigger asChild> + <Button + className="group w-full flex justify-start items-center min-w-0 gap-1.5 hover:no-underline hover:text-muted-foreground" + disabled={disabled || loading} + size="inline" + variant="text" + > + <Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" /> + <span className="shrink-0">{parts.before}</span> + <span className="shrink-0 text-primary underline-offset-4 decoration-current/20 group-hover:underline"> + {loading ? '...' : value} + </span> + <Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.75rem" /> + <span className="shrink-0">{parts.after}</span> + </Button> + </PopoverTrigger> + <PopoverContent align="start" className="z-[140] min-w-(--radix-popover-trigger-width) p-0"> + <Command filter={(searchValue, search) => (searchValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}> + <CommandInput autoFocus placeholder={p.baseBranchPlaceholder} /> + <CommandList className="max-h-64"> + <CommandEmpty>{p.baseBranchNone}</CommandEmpty> + <CommandGroup> + {sorted.map(branch => ( + <CommandItem + key={branch.name} + onSelect={() => { + onValueChange(branch.name) + setOpen(false) + }} + value={branch.name} + > + <div className="flex items-center justify-start gap-1.5"> + <Codicon + className="shrink-0 text-(--ui-text-tertiary)" + name={branch.isRemote ? 'repo' : 'git-branch'} + size="0.8rem" + /> + {branch.isDefault && ( + <span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">★</span> + )} + <span className="truncate">{branch.name}</span> + {value === branch.name && ( + <Codicon className="ml-auto shrink-0 text-(--ui-accent)" name="check" size="0.8rem" /> + )} + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + ) +} diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts index fcd18086abce..f4ca8424d2f2 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts @@ -494,6 +494,29 @@ describe('liveSessionProjectId', () => { expect(id).toBe('p_app') }) + + it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => { + // The bug: a fresh Windows session drops into the overlay before the next + // backend refresh; case-sensitive matching missed its project until then. + const id = liveSessionProjectId(makeSession('c:/work/notes/SUB'), [makeProject('p_notes', ['C:\\Work\\Notes'])]) + + expect(id).toBe('p_notes') + }) + + it('matches a root-relative WSL cwd (single backslash) case-insensitively', () => { + const id = liveSessionProjectId(makeSession('//wsl.localhost/Ubuntu/home/alice/PROJ'), [ + makeProject('p_proj', ['\\wsl.localhost\\Ubuntu\\home\\alice\\proj']) + ]) + + expect(id).toBe('p_proj') + }) + + it('keeps POSIX cwd matching case-sensitive (no false project match)', () => { + // Distinct case on POSIX is a distinct path → falls back to its own auto id. + expect(liveSessionProjectId(makeSession('/work/notes'), [makeProject('p_notes', ['/Work/Notes'])])).toBe( + '/work/notes' + ) + }) }) describe('overlayLiveLanes', () => { diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts index 899a59e69793..04e18f7c7b95 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -73,6 +73,27 @@ const segments = (path: string): string[] => /** A path with trailing separators stripped, for stable equality checks. */ const normalizePath = (path: null | string | undefined): string => (path ?? '').replace(/[/\\]+$/, '') +// Windows spellings: drive-letter (`C:\…`), UNC (`\\srv`, `//srv`), or any +// backslash-rooted path (`\wsl.localhost\…`). A single leading `/` stays POSIX. +// Mirrors the backend `_is_windows_path` so the live overlay places rows into +// the same project the backend tree would. +const isWindowsPath = (path: string): boolean => + /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\') || path.startsWith('//') + +/** + * Segments for identity comparison: Windows paths fold case (and separators, via + * {@link segments}) so `C:\Work` and `c:/work` are one lane; POSIX stays + * case-sensitive. Comparison-only — emitted ids/labels keep their spelling. + */ +const comparisonSegments = (path: string): string[] => { + const segs = segments(path) + + return isWindowsPath(path) ? segs.map(seg => seg.toLowerCase()) : segs +} + +/** Canonical per-host comparison key (separator/case/trailing-slash agnostic). */ +const pathKey = (path: null | string | undefined): string => comparisonSegments(path ?? '').join('/') + /** Last path segment. */ export const baseName = (path: string): string | undefined => segments(path).pop() @@ -317,8 +338,8 @@ export function mergeRepoWorktreeGroups( /** True when `target` equals `folder` or is nested under it (segment-wise). */ function isPathUnder(folder: string, target: string): boolean { - const f = segments(folder) - const t = segments(target) + const f = comparisonSegments(folder) + const t = comparisonSegments(target) if (!f.length || f.length > t.length) { return false @@ -347,9 +368,8 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro // No persisted repo root yet (brand-new session) → the cwd is the root. const repoRoot = (session.git_repo_root || '').trim() || cwd - const underRepo = cwd === repoRoot || cwd.startsWith(`${repoRoot}/`) || cwd.startsWith(`${repoRoot}\\`) - if (!underRepo) { + if (!isPathUnder(repoRoot, cwd)) { return null } @@ -423,7 +443,7 @@ export function overlayRepoLanes( live: SessionInfo[], removed: ReadonlySet<string> = NO_REMOVED ): SidebarWorkspaceTree { - const repoRoot = normalizePath(repo.path) + const repoRootKey = pathKey(repo.path) let changed = false // Snapshot lanes minus anything the user just deleted/archived. @@ -457,7 +477,7 @@ export function overlayRepoLanes( for (const g of lanes) { const lanePath = normalizePath(g.path) - if (!lanePath || lanePath === repoRoot || !isPathUnder(lanePath, cwd)) { + if (!lanePath || pathKey(lanePath) === repoRootKey || !isPathUnder(lanePath, cwd)) { continue } @@ -480,14 +500,14 @@ export function overlayRepoLanes( continue } - const placedPath = normalizePath(placed.path) + const placedKey = pathKey(placed.path) lane = lanes.find(g => g.id === placed.id) ?? (placed.isMain ? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase()) : undefined) ?? - (!placed.isMain && placedPath ? lanes.find(g => normalizePath(g.path) === placedPath) : undefined) + (!placed.isMain && placedKey ? lanes.find(g => pathKey(g.path) === placedKey) : undefined) if (!lane) { lane = { ...placed, sessions: [] } diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx index 1a32f68b2f5c..e184ac871a0d 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx @@ -30,6 +30,8 @@ import { copyPath, listRepoBranches, revealPath, startWorkInRepo, switchBranchIn import { SidebarCount, SidebarRowLead } from '../chrome' +import { BaseBranchPicker } from './base-branch-picker' + // Branch/worktree labels routinely share a long prefix (`bb/coding-context-…`), // so plain end-truncation (`truncate`) hides exactly the suffix that tells two // lanes apart — both render as "bb/coding-context…". Keep the tail pinned and @@ -142,6 +144,8 @@ export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemov // "New worktree": prompt for a branch name, then git spins up a fresh worktree // for that branch under the repo (the lightest way) and we open a new session // inside it. Naming is explicit — no auto-generated `hermes/work-<ts>` trees. +// The base branch defaults to the remote default (origin/HEAD); the user can +// pick any local or remote-tracking branch via a filterable combobox. export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onStarted: (path: string) => void }) { const { t } = useI18n() const s = t.sidebar @@ -152,6 +156,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS const [convertMode, setConvertMode] = useState(false) const [branches, setBranches] = useState<HermesGitBranch[]>([]) const [branchesLoading, setBranchesLoading] = useState(false) + const [selectedBase, setSelectedBase] = useState('') const loadBranches = useCallback(async () => { if (!repoPath) { @@ -181,7 +186,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS try { // Pass the typed value as both the dir slug source and the branch, so the // branch is exactly what the user named (the dir is slugified git-side). - const result = await startWorkInRepo(repoPath, { branch, name: branch }) + const result = await startWorkInRepo(repoPath, { base: selectedBase || undefined, branch, name: branch }) if (result) { onStarted(result.path) @@ -238,6 +243,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS onClick={() => { setConvertMode(false) setName('') + setSelectedBase('') setOpen(true) }} type="button" @@ -278,22 +284,30 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS </CommandList> </Command> ) : ( - <SanitizedInput - autoFocus - disabled={pending} - onKeyDown={event => { - if (event.key === 'Enter') { - event.preventDefault() - void submit() - } else if (event.key === 'Escape') { - setOpen(false) - } - }} - onValueChange={setName} - placeholder={p.branchPlaceholder} - sanitize={gitRef} - value={name} - /> + <> + <SanitizedInput + autoFocus + disabled={pending} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + void submit() + } else if (event.key === 'Escape') { + setOpen(false) + } + }} + onValueChange={setName} + placeholder={p.branchPlaceholder} + sanitize={gitRef} + value={name} + /> + <BaseBranchPicker + disabled={pending} + onValueChange={setSelectedBase} + repoPath={repoPath} + value={selectedBase} + /> + </> )} {convertMode ? ( diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts index 321300ee8d37..8bdc509a021a 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.ts @@ -16,6 +16,9 @@ const activeGateway = vi.fn<() => { request: typeof request } | null>(() => ({ r vi.mock('@/hermes', () => ({ renameSession: (...args: unknown[]) => renameSession(...(args as [])), + // profile.ts calls this at import (its $activeGatewayProfile subscribe fires + // immediately), pulled in transitively via session-states. + setApiRequestProfile: () => {}, HermesGateway: class {} })) diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 08c13550a435..8faee82b25e7 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -1,9 +1,17 @@ +import { useStore } from '@nanostores/react' import type * as React from 'react' import { useEffect, useRef, useState } from 'react' +import { closeAllTreeTabs, closeOtherTreeTabs, closeTreeTabsToRight, treeTabCloseTargets } from '@/components/pane-shell/tree/store' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' import { CopyButton } from '@/components/ui/copy-button' import { Dialog, @@ -13,7 +21,13 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { renameSession } from '@/hermes' import { useI18n } from '@/i18n' @@ -22,6 +36,7 @@ import { exportSession } from '@/lib/session-export' import { activeGateway } from '@/store/gateway' import { notify, notifyError } from '@/store/notifications' import { $activeSessionId, $selectedStoredSessionId, setSessions } from '@/store/session' +import { $sessionTiles, openSessionTile } from '@/store/session-states' import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows' import type { SessionTitleResponse } from '../../types' @@ -80,10 +95,31 @@ interface SessionActions { onBranch?: () => void onArchive?: () => void onDelete?: () => void + /** Close this surface (a tile tab) — omitted where nothing closes (sidebar + * rows, the main tab). */ + onClose?: () => void + /** TAB surfaces: the session is already a tab, so "Open in new tab" is + * nonsense there — sidebar rows/dropdowns keep it. */ + surface?: 'row' | 'tab' + /** The tab's layout-tree pane id (`session-tile:<id>` or `workspace`) — enables + * the Close-others / to-the-right / all tab verbs. Tab surfaces only. */ + tabPaneId?: string + /** The MAIN tab's escape hatch: hide the zone's tab bar (it sticky-shows + * once a tab is ever gained; this is the explicit off switch). */ + onHideTabBar?: () => void } type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem +/** A menu flavour (dropdown / context) — item + separator components. */ +interface MenuKit { + Item: MenuItem + Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator +} + +const DROPDOWN_KIT: MenuKit = { Item: DropdownMenuItem, Separator: DropdownMenuSeparator } +const CONTEXT_KIT: MenuKit = { Item: ContextMenuItem, Separator: ContextMenuSeparator } + interface ItemSpec { className?: string disabled: boolean @@ -101,26 +137,45 @@ function useSessionActions({ onPin, onBranch, onArchive, - onDelete + onDelete, + onClose, + onHideTabBar, + surface = 'row', + tabPaneId }: SessionActions) { const { t } = useI18n() const r = t.sidebar.row const [renameOpen, setRenameOpen] = useState(false) + const tiles = useStore($sessionTiles) + const selectedStoredSessionId = useStore($selectedStoredSessionId) - const pinItem: ItemSpec = { - disabled: !onPin, - icon: 'pin', - label: pinned ? r.unpin : r.pin, - onSelect: () => { - triggerHaptic('selection') - onPin?.() - } - } + // Already showing as a tab somewhere (a tile, or loaded in main — main IS + // a tab): offering "Open in new tab" again is noise. + const alreadyTabbed = sessionId === selectedStoredSessionId || tiles.some(tile => tile.storedSessionId === sessionId) - const items: ItemSpec[] = [ + const spec = (partial: Omit<ItemSpec, 'onSelect'> & { onSelect: () => void }): ItemSpec => partial + + // OPEN — where else this session can go. A tab surface IS a tab already, + // so it only offers the window hop (and its own Close, below). + const openItems: ItemSpec[] = [ + ...(surface === 'row' && !alreadyTabbed + ? [ + spec({ + disabled: !sessionId, + icon: 'browser', + label: r.openInNewTab, + onSelect: () => { + triggerHaptic('selection') + // Stack into the MAIN zone as a tab (center dock; the strip + // sticky-shows on gain) — the door to the tab bar. + openSessionTile(sessionId, 'center') + } + }) + ] + : []), ...(canOpenSessionWindow() ? [ - { + spec({ disabled: !sessionId, icon: 'link-external', label: r.newWindow, @@ -128,28 +183,14 @@ function useSessionActions({ triggerHaptic('selection') void openSessionInNewWindow(sessionId) } - } + }) ] - : []), - { - disabled: !sessionId, - icon: 'cloud-download', - label: r.export, - onSelect: () => { - triggerHaptic('selection') - void exportSession(sessionId, { profile, title }) - } - }, - { - disabled: !onBranch, - icon: 'git-branch', - label: r.branchFrom, - onSelect: () => { - triggerHaptic('selection') - onBranch?.() - } - }, - { + : []) + ] + + // IDENTITY — name/mark/reference the session. + const identityItems: ItemSpec[] = [ + spec({ disabled: !sessionId, icon: 'edit', label: r.rename, @@ -157,8 +198,96 @@ function useSessionActions({ triggerHaptic('selection') setRenameOpen(true) } - }, - { + }), + spec({ + disabled: !onPin, + icon: 'pin', + label: pinned ? r.unpin : r.pin, + onSelect: () => { + triggerHaptic('selection') + onPin?.() + } + }) + ] + + // WORK — derive/extract from the session. + const workItems: ItemSpec[] = [ + spec({ + disabled: !onBranch, + icon: 'git-branch', + label: r.branchFrom, + onSelect: () => { + triggerHaptic('selection') + onBranch?.() + } + }), + spec({ + disabled: !sessionId, + icon: 'cloud-download', + label: r.export, + onSelect: () => { + triggerHaptic('selection') + void exportSession(sessionId, { profile, title }) + } + }) + ] + + // TAB — close verbs that act on the strip (tabs only; a row isn't a tab). + const closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null + + const tabCloseItems: ItemSpec[] = + surface === 'tab' + ? [ + ...(onClose + ? [ + spec({ + disabled: false, + icon: 'close', + label: t.common.close, + onSelect: () => { + triggerHaptic('selection') + onClose() + } + }) + ] + : []), + ...(tabPaneId + ? [ + spec({ + disabled: !closeTargets?.others, + icon: 'close-all', + label: t.zones.closeOthers, + onSelect: () => { + triggerHaptic('selection') + closeOtherTreeTabs(tabPaneId) + } + }), + spec({ + disabled: !closeTargets?.right, + icon: 'arrow-right', + label: t.zones.closeToRight, + onSelect: () => { + triggerHaptic('selection') + closeTreeTabsToRight(tabPaneId) + } + }), + spec({ + disabled: !closeTargets?.all, + icon: 'clear-all', + label: t.zones.closeAll, + onSelect: () => { + triggerHaptic('selection') + closeAllTreeTabs(tabPaneId) + } + }) + ] + : []) + ] + : [] + + // DANGER — put it away / destroy it (delete stays last, destructive-red). + const dangerItems: ItemSpec[] = [ + spec({ disabled: !onArchive, icon: 'archive', label: r.archive, @@ -166,7 +295,7 @@ function useSessionActions({ triggerHaptic('selection') onArchive?.() } - }, + }), { className: 'text-destructive focus:text-destructive', disabled: !onDelete, @@ -187,11 +316,13 @@ function useSessionActions({ </Item> ) - const renderItems = (Item: MenuItem) => ( + const renderItems = (kit: MenuKit) => ( <> - {renderMenuItem(Item, pinItem)} + {openItems.map(item => renderMenuItem(kit.Item, item))} + {openItems.length > 0 && <kit.Separator />} + {identityItems.map(item => renderMenuItem(kit.Item, item))} <CopyButton - appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'} + appearance={kit.Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'} disabled={!sessionId} errorMessage={r.copyIdFailed} iconClassName="size-3.5 text-current" @@ -200,7 +331,30 @@ function useSessionActions({ onCopyError={err => notifyError(err, r.copyIdFailed)} text={sessionId} /> - {items.map(spec => renderMenuItem(Item, spec))} + <kit.Separator /> + {workItems.map(item => renderMenuItem(kit.Item, item))} + {tabCloseItems.length > 0 && ( + <> + <kit.Separator /> + {tabCloseItems.map(item => renderMenuItem(kit.Item, item))} + </> + )} + <kit.Separator /> + {dangerItems.map(item => renderMenuItem(kit.Item, item))} + {onHideTabBar && ( + <> + <kit.Separator /> + {renderMenuItem(kit.Item, { + disabled: false, + icon: 'eye-closed', + label: r.hideTabBar, + onSelect: () => { + triggerHaptic('selection') + onHideTabBar() + } + })} + </> + )} </> ) @@ -225,10 +379,11 @@ interface SessionActionsMenuProps export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) { const { t } = useI18n() const { renameDialog, renderItems } = useSessionActions(actions) + const [open, setOpen] = useState(false) return ( <> - <DropdownMenu> + <DropdownMenu onOpenChange={setOpen} open={open}> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuContent align={align} @@ -236,7 +391,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, .. className="w-40" sideOffset={sideOffset} > - {renderItems(DropdownMenuItem)} + {renderItems(DROPDOWN_KIT)} </DropdownMenuContent> </DropdownMenu> {renameDialog} @@ -257,7 +412,7 @@ export function SessionContextMenu({ children, ...actions }: SessionContextMenuP <ContextMenu> <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> <ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40"> - {renderItems(ContextMenuItem)} + {renderItems(CONTEXT_KIT)} </ContextMenuContent> </ContextMenu> {renameDialog} diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index d2543b9058aa..c6f8ebc7a332 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -1,7 +1,7 @@ import { useStore } from '@nanostores/react' import type * as React from 'react' -import { writeSessionDrag } from '@/app/chat/composer/inline-refs' +import { startSessionDrag } from '@/app/chat/session-drag' import { PlatformAvatar } from '@/app/messaging/platform-icon' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -13,7 +13,9 @@ import { triggerHaptic } from '@/lib/haptics' import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source' import { coarseElapsed } from '@/lib/time' import { cn } from '@/lib/utils' -import { $attentionSessionIds } from '@/store/session' +import { $backgroundRunningSessionIds } from '@/store/composer-status' +import { $attentionSessionIds, $unreadFinishedSessionIds } from '@/store/session' +import { openSessionTile } from '@/store/session-states' import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows' import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome' @@ -74,10 +76,26 @@ export function SidebarSessionRow({ // Telegram thread continued here still reads as Telegram. const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform) const handoffLabel = handoffSource ? (sessionSourceLabel(handoffSource) ?? handoffSource) : null - // Subscribe per-row (the leaf) instead of drilling a set through the list — - // the atom is tiny and rarely non-empty. True when a clarify prompt in this - // session is waiting on the user. + // True when a clarify prompt in this session is waiting on the user. const needsInput = useStore($attentionSessionIds).includes(session.id) + // True when the session's most recent turn finished in the background (while + // the user was viewing a different session) and hasn't been opened since. + const isUnread = useStore($unreadFinishedSessionIds).includes(session.id) + // True when a terminal(background=true) process is alive in this session. + const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id) + + // Resolve the dot's display state once — the four signals are mutually + // exclusive by priority, so threading them as booleans through wrappers just + // to collapse them at the leaf is backwards. + const dotState: SessionDotState = needsInput + ? 'needs-input' + : isWorking + ? 'working' + : hasBackground + ? 'background' + : isUnread + ? 'unread' + : 'idle' return ( <SessionContextMenu @@ -92,7 +110,7 @@ export function SidebarSessionRow({ > <SidebarRowShell actions={ - <div className="relative z-2 grid w-[1.375rem] place-items-center"> + <div className="relative z-2 grid w-[1.375rem] place-items-center" data-row-actions> {!isWorking && ( <span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100"> {age} @@ -130,21 +148,19 @@ export function SidebarSessionRow({ className )} data-working={isWorking ? 'true' : undefined} - draggable - onDragStart={event => { - // Reorder drags belong to dnd-kit (the grab handle) — cancel the - // native drag so the two DnD systems don't fight. - if ((event.target as HTMLElement).closest('[data-reorder-handle]')) { - event.preventDefault() - + onPointerDown={event => { + // Reorder drags belong to dnd-kit (the grab handle); the ⋯ actions + // cluster keeps its own gestures. Everything else on the row — + // including the row-body BUTTON, the natural grab surface — is a + // session drag source: a POINTER drag on the shared drag session + // (never native HTML5 DnD: no macOS snap-back, Esc aborts + // instantly). Sub-threshold releases stay ordinary clicks, so + // resume / pin / open-in-window are untouched. + if ((event.target as HTMLElement).closest('[data-reorder-handle], [data-row-actions]')) { return } - writeSessionDrag(event.dataTransfer, { - id: session.id, - profile: session.profile || 'default', - title - }) + startSessionDrag({ id: session.id, profile: session.profile || 'default', title }, event) }} ref={ref} style={style} @@ -153,7 +169,40 @@ export function SidebarSessionRow({ {isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />} <SidebarRowBody className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')} + // Middle-click = open in a new tab (browser muscle memory). Swallow + // the mousedown so Chromium doesn't enter autoscroll mode. + onAuxClick={event => { + if (event.button === 1) { + event.preventDefault() + event.stopPropagation() + triggerHaptic('selection') + openSessionTile(session.id, 'center') + } + }} onClick={event => { + const mod = event.metaKey || event.ctrlKey + + // ⇧⌘-click → pop into its own window (needs standalone windows). + if (mod && event.shiftKey && canOpenSessionWindow()) { + event.preventDefault() + event.stopPropagation() + triggerHaptic('selection') + void openSessionInNewWindow(session.id) + + return + } + + // ⌘/⌃-click → open in a new tab (stack into main). + if (mod) { + event.preventDefault() + event.stopPropagation() + triggerHaptic('selection') + openSessionTile(session.id, 'center') + + return + } + + // ⇧-click → pin. if (event.shiftKey) { event.preventDefault() event.stopPropagation() @@ -163,21 +212,9 @@ export function SidebarSessionRow({ return } - // ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own - // window — the universal "open in a new window" gesture. Archive - // lives in the row's ⋯ and right-click menus. Falls through to a - // normal resume when standalone windows aren't available (web embed). - if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) { - event.preventDefault() - event.stopPropagation() - triggerHaptic('selection') - void openSessionInNewWindow(session.id) - - return - } - onResume() }} + onMouseDown={event => event.button === 1 && event.preventDefault()} > {reorderable ? ( <SidebarRowGrab @@ -189,13 +226,12 @@ export function SidebarSessionRow({ <SessionRowLeadDot branchStem={branchStem} className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0" - isWorking={isWorking} - needsInput={needsInput} + dotState={dotState} /> </SidebarRowGrab> ) : ( <SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}> - <SessionRowLeadDot branchStem={branchStem} isWorking={isWorking} needsInput={needsInput} /> + <SessionRowLeadDot branchStem={branchStem} dotState={dotState} /> </SidebarRowLead> )} {handoffSource && handoffLabel ? ( @@ -216,15 +252,18 @@ export function SidebarSessionRow({ ) } +/** The session's display state for the sidebar lead dot. The call site + * resolves this from the four underlying signals (needs-input, working, + * background, unread) so the dot component itself is a pure lookup. */ +type SessionDotState = 'background' | 'idle' | 'needs-input' | 'unread' | 'working' + function SessionRowLeadDot({ branchStem, - isWorking, - needsInput = false, + dotState = 'idle', className }: { branchStem?: string - isWorking: boolean - needsInput?: boolean + dotState?: SessionDotState className?: string }) { return ( @@ -234,49 +273,77 @@ function SessionRowLeadDot({ {branchStem} </span> ) : null} - <SidebarRowDot isWorking={isWorking} needsInput={needsInput} /> + <SidebarRowDot dotState={dotState} /> </span> ) } -function SidebarRowDot({ - isWorking, - needsInput = false, - className -}: { - isWorking: boolean - needsInput?: boolean - className?: string -}) { +// A pure lookup table: each state maps to its className, aria-label, and +// title. No priority resolution here — the call site already picked one. +// Label/title are resolved from sidebar.row translations, keyed by name. +type DotVariant = { + ariaLabel?: (r: Translations['sidebar']['row']) => string + className: string + role?: 'status' + title?: (r: Translations['sidebar']['row']) => string +} + +// Shared base for every active dot; idle is smaller and uses its own class. +const DOT_BASE = 'relative size-1.5 rounded-full' + +// Pseudo-element ping ring that scales outward and fades — shared scaffold for +// the two pulsing dots. The `before:bg-*` color is written inline per variant +// (NOT interpolated here): Tailwind only generates utilities it can see as +// complete static strings, so a `before:bg-${color}` template never emits. +const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']" + +const DOT_VARIANTS: Record<SessionDotState, DotVariant> = { + // Amber steady — a clarify/approval is blocking the turn. Steady (not + // pulsing) reads as "your turn", distinct from the accent pulse of a turn. + 'needs-input': { + ariaLabel: r => r.needsInput, + className: `${DOT_BASE} quest-glow bg-amber-500`, + role: 'status', + title: r => r.waitingForAnswer + }, + // Accent pulse — the LLM turn is actively running. + working: { + ariaLabel: r => r.sessionRunning, + className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`, + role: 'status' + }, + // Pulsing gray — a terminal(background=true) process is alive while the LLM + // is idle. Gray (not accent) reads as "something chugging along". + background: { + ariaLabel: r => r.backgroundRunning, + className: `${DOT_BASE} bg-muted-foreground/50 ${PING} before:bg-muted-foreground/50 before:opacity-50`, + role: 'status', + title: r => r.backgroundRunning + }, + // Steady green — a background session's turn completed and the user hasn't + // opened it since. "Something new here, go look." + unread: { + ariaLabel: r => r.finishedUnread, + className: `${DOT_BASE} bg-emerald-500`, + role: 'status', + title: r => r.finishedUnread + }, + idle: { + className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80' + } +} + +function SidebarRowDot({ dotState, className }: { dotState: SessionDotState; className?: string }) { const { t } = useI18n() const r = t.sidebar.row - - // "Needs input" wins over "working": a clarify-blocked session is technically - // still running, but the actionable state is that it's waiting on the user. - // Amber + steady (no ping) reads as "your turn", distinct from the accent - // pulse of an active turn. - if (needsInput) { - return ( - <span - aria-label={r.needsInput} - className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)} - role="status" - title={r.waitingForAnswer} - /> - ) - } + const variant = DOT_VARIANTS[dotState] return ( <span - aria-label={isWorking ? r.sessionRunning : undefined} - className={cn( - 'rounded-full', - isWorking - ? "relative size-1.5 bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] before:absolute before:inset-0 before:animate-ping before:rounded-full before:bg-(--ui-accent) before:opacity-70 before:content-['']" - : 'size-1 bg-(--ui-text-quaternary) opacity-80', - className - )} - role={isWorking ? 'status' : undefined} + aria-label={variant.ariaLabel?.(r)} + className={cn(variant.className, className)} + role={variant.role} + title={variant.title?.(r)} /> ) } diff --git a/apps/desktop/src/app/chat/sidebar/split-submenu.tsx b/apps/desktop/src/app/chat/sidebar/split-submenu.tsx new file mode 100644 index 000000000000..695216cbc4e7 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/split-submenu.tsx @@ -0,0 +1,92 @@ +import { Codicon } from '@/components/ui/codicon' +import { + ContextMenuItem, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger +} from '@/components/ui/context-menu' +import { + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { triggerHaptic } from '@/lib/haptics' +import type { SplitDir } from '@/store/session-states' + +/** The leaf + submenu components for one menu flavour, so the split submenu + * renders in either the `…` dropdown or a right-click context menu. */ +export interface SplitMenuKit { + Item: typeof DropdownMenuItem | typeof ContextMenuItem + Sub: typeof DropdownMenuSub | typeof ContextMenuSub + SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent + SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger +} + +export const DROPDOWN_SPLIT_KIT: SplitMenuKit = { + Item: DropdownMenuItem, + Sub: DropdownMenuSub, + SubContent: DropdownMenuSubContent, + SubTrigger: DropdownMenuSubTrigger +} + +export const CONTEXT_SPLIT_KIT: SplitMenuKit = { + Item: ContextMenuItem, + Sub: ContextMenuSub, + SubContent: ContextMenuSubContent, + SubTrigger: ContextMenuSubTrigger +} + +// Ordered so the default (right) sits first, one hop away. +const SPLIT_DIRS: { dir: SplitDir; icon: string; label: string }[] = [ + { dir: 'right', icon: 'arrow-right', label: 'Right' }, + { dir: 'bottom', icon: 'arrow-down', label: 'Down' }, + { dir: 'left', icon: 'arrow-left', label: 'Left' }, + { dir: 'top', icon: 'arrow-up', label: 'Up' } +] + +interface SplitSubmenuProps { + kit: SplitMenuKit + label: string + onSplit: (dir: SplitDir) => void + disabled?: boolean + /** Dismiss the owning menu after the row's default (right) split — the + * dropdown is controlled and can; a context menu can't, so it's a no-op. */ + close?: () => void +} + +/** + * "Open in split ▸": clicking the row splits right (the common case), and the + * submenu picks any edge. Shared by session rows and page nav rows. + */ +export function SplitSubmenu({ close, disabled, kit, label, onSplit }: SplitSubmenuProps) { + const { Item, Sub, SubContent, SubTrigger } = kit + + const split = (dir: SplitDir) => { + triggerHaptic('selection') + onSplit(dir) + } + + return ( + <Sub> + <SubTrigger + disabled={disabled} + onClick={() => { + split('right') + close?.() + }} + > + <Codicon name="split-horizontal" size="0.875rem" /> + <span>{label}</span> + </SubTrigger> + <SubContent> + {SPLIT_DIRS.map(({ dir, icon, label: dirLabel }) => ( + <Item key={dir} onSelect={() => split(dir)}> + <Codicon name={icon} size="0.875rem" /> + <span>{dirLabel}</span> + </Item> + ))} + </SubContent> + </Sub> + ) +} diff --git a/apps/desktop/src/app/command-palette/contrib.ts b/apps/desktop/src/app/command-palette/contrib.ts new file mode 100644 index 000000000000..bd3e669cbe33 --- /dev/null +++ b/apps/desktop/src/app/command-palette/contrib.ts @@ -0,0 +1,28 @@ +/** + * Command-palette contribution surface — `palette` data contributions become + * rows in the ⌘K root list, same schema as every other area. Contributions + * with an `action` id render that action's live keybind as their hotkey hint. + */ + +import { useContributions } from '@/contrib/react/use-contributions' +import type { IconComponent } from '@/lib/icons' + +export const PALETTE_AREA = 'palette' + +/** Payload of a `palette` data contribution. */ +export interface PaletteContribution { + id: string + label: string + /** Keybind action id — its live combo renders as the hotkey hint. */ + action?: string + icon?: IconComponent + keywords?: string[] + run: () => void +} + +/** Contributed palette rows, with stable render keys. */ +export function usePaletteContributions(): Array<PaletteContribution & { key: string }> { + return useContributions(PALETTE_AREA) + .map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as PaletteContribution) })) + .filter(item => Boolean(item.label && item.run)) +} diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index be89ebb4e128..09d6dac8a2a1 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -80,6 +80,7 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants' import { fieldCopyForSchemaKey } from '../settings/field-copy' import { prettyName } from '../settings/helpers' +import { usePaletteContributions } from './contrib' import { MarketplaceThemePage } from './marketplace-theme-page' import { PetInlineToggle, PetPalettePage } from './pet-palette-page' @@ -117,6 +118,7 @@ interface PalettePage { } interface SessionEntry { + git_branch?: null | string id: string preview?: string title: string @@ -211,6 +213,7 @@ const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/ type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number] const toSessionEntry = (session: SessionRow): SessionEntry => ({ + git_branch: session.git_branch ?? null, id: session.id, preview: session.preview ?? undefined, title: sessionTitle(session) @@ -368,6 +371,8 @@ export function CommandPalette() { [t.settings.fieldLabels] ) + const contributedItems = usePaletteContributions() + const baseGroups = useMemo<PaletteGroup[]>(() => { const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}` const cc = t.commandCenter @@ -559,9 +564,26 @@ export function CommandPalette() { run: go(settingsTab(entry.tab)) })) ] - } + }, + // Registry-contributed rows (core features + plugins) — one group, + // omitted while nothing contributes. + ...(contributedItems.length > 0 + ? [ + { + heading: cc.commands, + items: contributedItems.map(item => ({ + action: item.action, + icon: item.icon ?? Zap, + id: item.key, + keywords: item.keywords, + label: item.label, + run: item.run + })) + } + ] + : []) ] - }, [go, settingsSectionLabel, t, worktrees]) + }, [contributedItems, go, settingsSectionLabel, t, worktrees]) // The long, granular lists (settings fields, API keys, MCP servers, archived // chats) only surface once the user types — otherwise they'd bury the @@ -664,7 +686,7 @@ export function CommandPalette() { items: sessions.map(session => ({ icon: MessageCircle, id: `session-${session.id}`, - keywords: ['chat', 'session', ...(session.preview ? [session.preview] : [])], + keywords: ['chat', 'session', ...(session.preview ? [session.preview] : []), ...(session.git_branch ? [session.git_branch] : [])], label: session.title, run: go(sessionRoute(session.id)) })) @@ -702,7 +724,7 @@ export function CommandPalette() { items: archivedSessions.map(session => ({ icon: Archive, id: `archived-${session.id}`, - keywords: ['archived', 'chat', 'session', ...(session.preview ? [session.preview] : [])], + keywords: ['archived', 'chat', 'session', ...(session.preview ? [session.preview] : []), ...(session.git_branch ? [session.git_branch] : [])], label: session.title, run: go(`${SETTINGS_ROUTE}?tab=sessions&session=${encodeURIComponent(session.id)}`) })) diff --git a/apps/desktop/src/app/contrib/context.tsx b/apps/desktop/src/app/contrib/context.tsx new file mode 100644 index 000000000000..8c90662bc4f8 --- /dev/null +++ b/apps/desktop/src/app/contrib/context.tsx @@ -0,0 +1,36 @@ +import { createContext, memo, useContext } from 'react' + +import { DecodeText } from '@/components/ui/decode-text' + +import { StatusbarControls } from '../shell/statusbar-controls' + +import type { WiringApi } from './types' + +/** The controller publishes its wired surfaces here; every registered pane + * / chrome slot reads one back through `WiredPane`. */ +export const ContribWiringContext = createContext<WiringApi | null>(null) + +/** Render a wired surface inside a registered pane / chrome slot. + * + * Memoized on `part` (its only prop): a zone re-rendering for reasons that + * don't touch the wiring — a drag hint sweeping the tree, a sash resize, an + * edit-mode toggle — re-renders the group chrome but NOT this component, so + * the (expensive) pane body it reads from context is untouched. When the + * wiring's `api` genuinely changes, the context read re-renders it as normal. */ +export const WiredPane = memo(function WiredPane({ part }: { part: keyof WiringApi }) { + const api = useContext(ContribWiringContext) + + if (!api) { + if (part === 'statusbar') { + return <StatusbarControls items={[]} leftItems={[]} /> + } + + return ( + <div className="grid h-full place-items-center"> + <DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" /> + </div> + ) + } + + return <>{api[part]}</> +}) diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx new file mode 100644 index 000000000000..c82ac082db4f --- /dev/null +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -0,0 +1,673 @@ +import { useStore } from '@nanostores/react' +import { computed } from 'nanostores' +import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react' + +import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail' +import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib' +import { type StatusbarItem } from '@/app/shell/statusbar-controls' +import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode' +import { allPaneIds, group, split } from '@/components/pane-shell/tree/model' +import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer' +import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session' +import { + $layoutTree, + bindTreeSideVisibility, + declareDefaultTree, + dismissTreePane, + dockPaneBeside, + markCollapsePane, + mirrorLayoutTree, + paneRootSide, + registerLayoutResetHandler, + registerPaneCloser, + registerPaneOpener, + resetLayoutTree, + revealTreePane, + setPaneCollapsed, + setTreePaneHidden, + watchContributedPanes +} from '@/components/pane-shell/tree/store' +import { SidebarProvider } from '@/components/ui/sidebar' +import { discoverBundledPlugins } from '@/contrib/plugins' +import { Slot } from '@/contrib/react/slot' +import { registry } from '@/contrib/registry' +import { discoverRuntimePlugins } from '@/contrib/runtime-loader' +import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime' +import { LayoutDashboard } from '@/lib/icons' +import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' +import { Codecs, persistentAtom } from '@/lib/persisted' +import { toggleKeybindPanel } from '@/store/keybinds' +import { + $fileBrowserOpen, + $panesFlipped, + $sidebarOpen, + FILE_BROWSER_DEFAULT_WIDTH, + FILE_BROWSER_MAX_WIDTH, + FILE_BROWSER_MIN_WIDTH, + setFileBrowserOpen, + setSidebarOpen, + SIDEBAR_DEFAULT_WIDTH, + SIDEBAR_MAX_WIDTH +} from '@/store/layout' +import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview' +import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review' +import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session' + +import type { SessionDragPayload } from '../chat/composer/inline-refs' +import { watchRouteTiles } from '../chat/route-tile' +import { startSessionDrag } from '../chat/session-drag' +import { + SessionTileCloseConfirm, + stackSessionTilesIntoMain, + watchSessionTiles, + WorkspaceTabMenu +} from '../chat/session-tile' +import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store' +import { $workspaceIsPage } from '../routes' + +import { FilesPane, LogsPane, PreviewRailPane, ReviewPaneContent } from './panes' +import { ContribWiring, WiredPane } from './wiring' + +/** + * Stripped-down app root (bb/contrib-areas) on the layout TREE model, mounting + * the REAL app surfaces. The title bar and status bar sit OUTSIDE the grid + * (fixed chrome) but are fully composable: title bar renders `titleBar.left/ + * right` slots; the status bar consumes `statusBar.left/right` DATA + * contributions (payload = StatusbarItem). Core registers its items through + * the same calls a plugin would use. + */ + +// --------------------------------------------------------------------------- +// Pane contributions. `data.placement` = semantic role for grid presets; +// `data.minWidth/maxWidth/minHeight/maxHeight` = the SAME clamps the app's +// `Pane` props declare — the layout tree sizes zones by weight (percentage) +// but a zone never shrinks/grows past its active pane's clamp. +// Headers are contextual (tree-side): a pane alone in a zone shows no +// header/tab by default; stacked panes show chips. Double-click a zone +// toggles its header either way. +// --------------------------------------------------------------------------- + +// ONE render identity for the workspace pane — syncWorkspaceTitle re-registers +// the contribution (new title) and a fresh closure would remount the chat. +const renderWorkspacePane = () => <WiredPane part="chatRoutes" /> +// The main tab carries the same session context menu as tile tabs (targets +// the loaded primary session; no menu on a fresh draft). +const wrapWorkspaceTab = (tab: ReactElement) => <WorkspaceTabMenu>{tab}</WorkspaceTabMenu> + +/** The `@session` payload for the workspace tab — the loaded primary session, + * or null on a fresh draft / full-page view (nothing to link). */ +const workspaceDragPayload = (): SessionDragPayload | null => { + const selected = $selectedStoredSessionId.get() + + if (!selected || $workspaceIsPage.get()) { + return null + } + + const stored = $sessions.get().find(s => sessionMatchesStoredId(s, selected)) + + return { id: selected, profile: stored?.profile ?? '', title: stored ? storedSessionTitle(stored) : '' } +} + +// The main tab drags like a session tile — drop it on a composer to link the +// chat, on a zone/edge to stack/split. Defers (`false`) to the generic pane +// move when there's no loaded session to carry. +const workspaceTabDrag = (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) => { + const payload = workspaceDragPayload() + + if (!payload) { + return false + } + + startSessionDrag(payload, event, { double, onTap }) + + return true +} + +registry.registerMany([ + { + id: 'sessions', + area: 'panes', + title: 'sessions', + // Collapsible: leaves the grid on narrow viewports (edge overlay instead). + // dock: where a RE-ADOPTED pane lands (healed from a stale dismissal) — + // its default-ish spot beside main, not a random same-placement stack. + data: { + placement: 'left', + collapsible: true, + dock: { pane: 'workspace', pos: 'left' }, + revealAliases: ['chat-sidebar'], + width: `${SIDEBAR_DEFAULT_WIDTH}px`, + minWidth: `${SIDEBAR_DEFAULT_WIDTH}px`, + maxWidth: `${SIDEBAR_MAX_WIDTH}px` + }, + render: () => <WiredPane part="sidebar" /> + }, + { + id: 'workspace', + area: 'panes', + // Live-retitled to the loaded session by syncWorkspaceTitle below. + title: 'New session', + data: { + placement: 'main', + minWidth: '22vw', + tabDrag: workspaceTabDrag, + tabWrap: wrapWorkspaceTab, + uncloseable: true + }, + render: renderWorkspacePane + }, + { + id: 'terminal', + area: 'panes', + title: 'terminal', + // revealOnPreset: choosing a layout that places the terminal (e.g. + // "Terminal deck") turns takeover on so the zone actually shows, instead of + // staying collapsed behind the ⌃` toggle. height sizes the fixed track (a + // single-pane zone declaring a height is a fixed track — the preset weight + // is moot): a short deck, not a third of the window. + data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true }, + render: () => <WiredPane part="terminal" /> + }, + { + id: 'files', + area: 'panes', + title: 'files', + // dock: re-adoption target after a stale dismissal (see sessions). + data: { + placement: 'right', + collapsible: true, + dock: { pane: 'workspace', pos: 'right' }, + revealAliases: ['file-browser'], + width: FILE_BROWSER_DEFAULT_WIDTH, + minWidth: FILE_BROWSER_MIN_WIDTH, + maxWidth: FILE_BROWSER_MAX_WIDTH + }, + render: () => <FilesPane /> + }, + { + id: 'preview', + area: 'panes', + title: 'preview', + // The rail brings its OWN tab strip (per-target tabs with close buttons). + // Exists only while something is previewed — visibility is bound to the + // preview targets below, like every other self-managed surface. dock: + // adoption seed only — dockPaneBeside re-docks it next to files on every + // reveal anyway (position-aware). + data: { + placement: 'right', + dock: { pane: 'files', pos: 'left' }, + width: 'clamp(18rem, 36vw, 32rem)', + minWidth: PREVIEW_RAIL_MIN_WIDTH, + maxWidth: PREVIEW_RAIL_MAX_WIDTH + }, + render: () => <PreviewRailPane /> + }, + { + id: 'review', + area: 'panes', + title: 'review', + // The second right sidebar: hidden until ⌘G ($reviewOpen) — bound below + // like the other chrome toggles; its zone collapses while hidden. + data: { + placement: 'right', + collapsible: true, + revealAliases: [REVIEW_PANE_ID], + width: FILE_BROWSER_DEFAULT_WIDTH, + minWidth: FILE_BROWSER_MIN_WIDTH, + maxWidth: FILE_BROWSER_MAX_WIDTH + }, + render: () => <ReviewPaneContent /> + }, + { + // Optional chrome — in NO default layout. Adoption stacks it with the + // terminal; $logsOpen (default off, ⌘K "Toggle logs") reveals it. + id: 'logs', + area: 'panes', + title: 'logs', + // revealOnPreset: the Quad layout places logs, so applying it turns the + // logs pane on (like a ⌘K "Toggle logs") instead of leaving it collapsed. + data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true }, + render: () => <LogsPane /> + } +]) + +// --------------------------------------------------------------------------- +// Chrome contributions. The title bar and status bar are fixed chrome outside +// the grid, composable through these areas. Everything real lives in the real +// components (TitlebarControls / useStatusbarItems). Sample PLUGIN +// contributions don't live here — they're their own files under `src/plugins/`, +// auto-discovered by discoverBundledPlugins() below. +// --------------------------------------------------------------------------- + +registry.registerMany([ + // Titlebar center stays empty on purpose: session title lives in tabs + + // sidebar; place/cwd lives in the sidebar project tree. Center is drag + // chrome (plugins can still contribute to titleBar.center if needed). + // Layout edit mode registers through the SAME declarative surfaces plugins + // use: a rebindable keybind (collision-checked in the panel) + a ⌘K row + // whose hotkey hint tracks the live binding. + { + id: 'layout.editMode', + area: KEYBINDS_AREA, + data: { + id: 'layout.editMode', + label: 'Toggle layout edit mode', + defaults: ['mod+shift+\\'], + run: toggleLayoutEditMode + } satisfies KeybindContribution + }, + { + id: 'layout.editMode', + area: PALETTE_AREA, + data: { + id: 'layout.editMode', + label: 'Toggle layout edit mode', + action: 'layout.editMode', + icon: LayoutDashboard, + keywords: ['layout', 'zones', 'panes', 'edit', 'rearrange'], + run: toggleLayoutEditMode + } satisfies PaletteContribution + }, + // The agent's write -> see loop: rescan <hermes home>/desktop-plugins + // without relaunching (same-id reloads dispose the previous incarnation). + { + id: 'plugins.reload', + area: PALETTE_AREA, + data: { + id: 'plugins.reload', + label: 'Reload desktop plugins', + keywords: ['plugins', 'reload', 'refresh', 'desktop'], + run: () => void discoverRuntimePlugins() + } satisfies PaletteContribution + }, + { + id: 'layout.reset', + area: PALETTE_AREA, + data: { + id: 'layout.reset', + label: 'Reset layout', + icon: LayoutDashboard, + keywords: ['layout', 'reset', 'default', 'panes'], + run: resetLayoutTree + } satisfies PaletteContribution + }, + // The keybind panel's non-titlebar door (the keyboard icon is gone). + { + id: 'keybinds.panel', + area: PALETTE_AREA, + data: { + id: 'keybinds.panel', + label: 'Keyboard shortcuts', + keywords: ['keybinds', 'shortcuts', 'hotkeys', 'keyboard'], + run: toggleKeybindPanel + } satisfies PaletteContribution + } +]) + +// --------------------------------------------------------------------------- +// Layout presets — CHAT (main) always dominates. +// --------------------------------------------------------------------------- + +// The REAL default: sessions left, chat main, and the right sidebars in +// column order main | … | review | preview | file-browser (files outermost, +// preview DIRECTLY left of the file tree). Each is its OWN zone — main +// parity: a file double-click slides the preview open as its own pane beside +// the tree, never as a tab stacked into the files sidebar. Preview/review +// zones collapse to nothing while their pane is hidden (no target / ⌘G off). +// This static spot is just the seed — dockPaneBeside keeps preview adjacent +// to files WHEREVER files moves (see the target listeners below). +const DEFAULT_TREE = split( + 'row', + [ + group(['sessions'], { id: 'grp-sessions' }), + group(['workspace'], { id: 'grp-main' }), + split( + 'column', + [ + split( + 'row', + [ + group(['review'], { id: 'grp-review' }), + group(['preview'], { id: 'grp-preview' }), + group(['files'], { id: 'grp-files' }) + ], + [1, 1, 1.2], + 'spl-rail' + ), + group(['terminal'], { id: 'grp-terminal' }) + ], + [1.6, 1], + 'spl-right' + ) + ], + [1, 3.4, 1.25], + 'spl-root' +) + +const FOCUS_TREE = split( + 'row', + [group(['sessions']), group(['workspace', 'files', 'preview', 'review', 'terminal'])], + [1, 4.6] +) + +const TERMINAL_TREE = split( + 'column', + [ + split('row', [group(['sessions']), group(['workspace']), group(['files', 'preview', 'review'])], [1, 3.2, 1.2]), + group(['terminal']) + ], + [3, 1] +) + +const QUAD_TREE = split( + 'column', + [ + split('row', [group(['sessions', 'files']), group(['workspace'])], [1, 3]), + split('row', [group(['terminal']), group(['preview', 'review', 'logs'])], [1.4, 1]) + ], + [3, 1] +) + +registry.registerMany([ + { id: 'default', area: 'layouts', title: 'Default', order: 0, data: DEFAULT_TREE }, + { id: 'focus', area: 'layouts', title: 'Focus', order: 10, data: FOCUS_TREE }, + { id: 'terminal-deck', area: 'layouts', title: 'Terminal deck', order: 20, data: TERMINAL_TREE }, + { id: 'quad', area: 'layouts', title: 'Quad', order: 30, data: QUAD_TREE } +]) + +declareDefaultTree(DEFAULT_TREE) + +// Bundled plugins load AFTER core, so a same-id contribution from a plugin +// deliberately overrides the core default (last writer wins). Third-party +// runtime plugins will flow through the same discovery seam. +discoverBundledPlugins() + +// Plugin panes join the tree by their `placement` hint the moment they +// register — incl. runtime plugins arriving seconds after boot. +watchContributedPanes() + +// Session + route (page) tiles: persisted splits register panes docked beside +// main. +watchSessionTiles() +watchRouteTiles() + +// The main tab reads as its SESSION (the loaded title, "New session" on a +// fresh draft) — a stack of main + tiles is then just a row of session names. +// register() replaces same-id in place; the render fn is the shared constant +// above, so the pane content never remounts. +const syncWorkspaceTitle = () => { + const selected = $selectedStoredSessionId.get() + const stored = selected ? $sessions.get().find(s => sessionMatchesStoredId(s, selected)) : null + + registry.register({ + id: 'workspace', + area: 'panes', + title: stored ? storedSessionTitle(stored) : 'New session', + data: { + // Pages aren't tab-able: the main zone's bar stands down while one shows. + headerVeto: $workspaceIsPage.get(), + placement: 'main', + minWidth: '22vw', + tabDrag: workspaceTabDrag, + tabWrap: wrapWorkspaceTab, + uncloseable: true + }, + render: renderWorkspacePane + }) +} + +$selectedStoredSessionId.listen(syncWorkspaceTitle) +$sessions.listen(syncWorkspaceTitle) +$workspaceIsPage.listen(syncWorkspaceTitle) + +// Layout reset collapses every session tile into main as a tab (after the +// workspace) instead of re-scattering them — pre-placed before adoption. +registerLayoutResetHandler(stackSessionTilesIntoMain) + +// --------------------------------------------------------------------------- +// Titlebar chrome toggles -> tree. The TitlebarControls buttons keep their +// store semantics ($sidebarOpen / $fileBrowserOpen / $panesFlipped); the tree +// reacts — a hidden pane's zone collapses (content stays mounted), the flip +// toggle mirrors the root row. +// --------------------------------------------------------------------------- + +function bindPaneVisibility( + paneId: string, + $open: { get(): boolean; listen(fn: (open: boolean) => void): void }, + close?: () => void, + open?: () => void +) { + setTreePaneHidden(paneId, !$open.get()) + $open.listen(isOpen => setTreePaneHidden(paneId, !isOpen)) + + // The tab menu's Close routes through the owning store (never dismissal), + // so the pane's toggle buttons stay truthful. + if (close) { + registerPaneCloser(paneId, close) + } + + // The opener is the mirror: preset application (revealOnPreset) shows the + // pane through the same store, so the toggle stays truthful. + if (open) { + registerPaneOpener(paneId, open) + } +} + +// TOOL PANELS (terminal, logs): like bindPaneVisibility but the toggle COLLAPSES +// the zone to a persistent rail (tab stays) instead of hiding it — the +// IntelliJ/VS-Code tool-window model. Restore routes back through `open` (rail +// click / chevron) so ⌃`/the button stay truthful; the tab's ✕ removes it. +function bindPaneCollapse( + paneId: string, + $open: { get(): boolean; listen(fn: (open: boolean) => void): void }, + close: () => void, + open: () => void +) { + markCollapsePane(paneId) + setPaneCollapsed(paneId, !$open.get()) + $open.listen(isOpen => setPaneCollapsed(paneId, !isOpen)) + registerPaneCloser(paneId, close) + registerPaneOpener(paneId, open) +} + +// SIDES have one source of truth: the TREE. The legacy $panesFlipped flag is +// DERIVED from where the sessions zone actually sits (TitlebarControls maps +// its left/right buttons through it), so dragging sessions across — or +// applying a mirrored preset — remaps the buttons automatically. The flip +// action (⌘\ / titlebar) mirrors the tree only when they disagree. +const sessionsOnRight = () => { + const tree = $layoutTree.get() + + if (!tree) { + return null + } + + const order = allPaneIds(tree) + const sessions = order.indexOf('sessions') + const main = order.indexOf('workspace') + + return sessions >= 0 && main >= 0 ? sessions > main : null +} + +$layoutTree.subscribe(() => { + const flipped = sessionsOnRight() + + if (flipped !== null && flipped !== $panesFlipped.get()) { + $panesFlipped.set(flipped) + } +}) + +$panesFlipped.listen(flipped => { + const current = sessionsOnRight() + + if (current !== null && current !== flipped) { + mirrorLayoutTree() + } +}) + +// POSITIONAL side toggles (titlebar buttons, ⌘B / ⌘J): $sidebarOpen ≙ the +// LEFT side of the main zone, $fileBrowserOpen ≙ the RIGHT — everything on +// that side hides together, whatever panes have been rearranged there. +bindTreeSideVisibility('left', $sidebarOpen, setSidebarOpen) +bindTreeSideVisibility('right', $fileBrowserOpen, setFileBrowserOpen) + +// Workspace-scoped surfaces: the file tree and git diff only mean something +// inside a project. A detached chat (no cwd) hides them — their zones +// collapse and the chat absorbs the width; picking a project brings them +// back. The terminal is NOT workspace-gated: unlike the old shell (where it +// rode the rail's row and vanished with it), its zone stands on its own. +const $hasWorkspace = computed($currentCwd, cwd => Boolean(cwd.trim())) + +bindPaneVisibility('files', $hasWorkspace) +// ⌘G — the review sidebar appears/disappears (and comes to the front). +bindPaneVisibility( + 'review', + computed([$reviewOpen, $hasWorkspace], (open, workspace) => open && workspace), + closeReview +) +// ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not +// hides; PTYs stay alive while collapsed (see PersistentTerminal). +bindPaneCollapse( + 'terminal', + $terminalTakeover, + () => setTerminalTakeover(false), + () => setTerminalTakeover(true) +) + +// Preview EXISTS only while something is previewed (old-shell semantics: +// closing the last preview tab closes the pane; a new target opens + fronts +// it). Same visibility binding as every other self-managed surface, driven +// by the live targets instead of a toggle. +const $previewVisible = computed([$previewTarget, $filePreviewTarget], (target, fileTarget) => + Boolean(target || fileTarget) +) + +bindPaneVisibility('preview', $previewVisible, closeRightRail) + +// Logs are optional chrome: off by default, toggled from ⌘K, persisted. +const $logsOpen = persistentAtom('hermes.desktop.logsOpen', false, Codecs.bool) + +bindPaneCollapse( + 'logs', + $logsOpen, + () => $logsOpen.set(false), + () => $logsOpen.set(true) +) +registry.register({ + id: 'logs.toggle', + area: PALETTE_AREA, + data: { + id: 'logs.toggle', + label: 'Toggle logs', + keywords: ['logs', 'agent log', 'tail', 'debug'], + run: () => $logsOpen.set(!$logsOpen.get()) + } satisfies PaletteContribution +}) + +// Sessions/files Close = collapse their SIDE (⌘B/⌘J truthful, titlebar button +// flips back) — but only while the pane actually lives in that root side +// column. Dragged next to main, a side collapse can't hide it (the collapse +// skips main-bearing children), so Close falls back to dismissal there — +// otherwise ⌘W/Close silently no-op. +registerPaneCloser('sessions', () => + paneRootSide('sessions') === 'left' ? setSidebarOpen(false) : dismissTreePane('sessions') +) +registerPaneCloser('files', () => + paneRootSide('files') === 'right' ? setFileBrowserOpen(false) : dismissTreePane('files') +) + +// A preview target lands NEXT TO the file tree — position-aware: wherever +// files currently lives (default rail, ⌘\-flipped, dragged into a stack), the +// preview zone docks directly beside it. A user who drags the preview pane +// somewhere pins it there instead (until a preset/reset). Then reveal: open +// the side, unhide, front — a NEW target while already visible still fronts. +const revealPreview = () => { + dockPaneBeside('preview', 'files') + revealTreePane('preview') +} + +$previewTarget.listen(target => target && revealPreview()) +$filePreviewTarget.listen(target => target && revealPreview()) + +// --------------------------------------------------------------------------- + +export function ContribController() { + const sidebarOpen = useStore($sidebarOpen) + + return ( + <SidebarProvider + className="h-screen min-h-0 flex-col bg-background" + onOpenChange={setSidebarOpen} + open={sidebarOpen} + style={{ '--sidebar-width': '100%' } as CSSProperties} + > + <ContribWiring> + <div + className="flex h-screen min-h-0 w-screen flex-col bg-(--ui-bg-chrome) text-(--ui-text-primary)" + style={{ '--titlebar-height': '0px' } as CSSProperties} + > + {/* Title bar: fixed chrome outside the grid, composable via slots. + Layout contract (no contribution can break it): + - a full-bar DRAG BASE underneath (pointer-events-none, like + AppShell's drag strips) — everywhere without content drags + the window; + - each slot region is width-fit, no-drag, pointer-events-auto, + so every contribution is clickable by construction; + - LEFT/RIGHT slots align to the MAIN PANE's geometry via the + tree-published --workspace-left/right vars (pure CSS, no rect + threading), clamped to clear the REAL TitlebarControls + clusters (fixed, z-70); center is truly window-centered. */} + <div className="relative flex h-[34px] shrink-0 items-center border-b border-(--ui-stroke-tertiary) text-xs"> + {/* Drag strips, AppShell-style: cut to AVOID the fixed control + clusters instead of overlapping them — Electron's no-drag + carve-out of fixed/transformed elements is unreliable, so a + full-bar drag base kills their clicks. In-flow slot content + still carves via its own no-drag wrapper (the same pattern as + the app's session-title button). */} + <div + aria-hidden="true" + className="pointer-events-none absolute inset-y-0 left-0 w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]" + /> + <div + aria-hidden="true" + className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,1.25rem)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]" + /> + <div + className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]" + style={{ + left: 'max(calc(var(--workspace-left, 0px) + 0.5rem), calc(var(--titlebar-controls-left, 14px) + 2 * var(--titlebar-control-size, 1.25rem) + 1rem))' + }} + > + <Slot area="titleBar.left" /> + </div> + <div className="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex w-max -translate-x-1/2 -translate-y-1/2 items-center gap-2 [-webkit-app-region:no-drag]"> + <Slot area="titleBar.center" /> + </div> + <div + className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]" + style={{ + right: + 'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 4 * (var(--titlebar-control-size, 1.25rem) + 0.25rem) + 0.5rem))' + }} + > + <Slot area="titleBar.right" /> + </div> + </div> + + <LayoutTreeRoot /> + + {/* "Close running tab?" — the busy/input-blocked tile close gate. */} + <SessionTileCloseConfirm /> + + {/* The REAL statusbar (model pill, command center, agents, …) with + statusBar.left/right contributions merged in. */} + <WiredPane part="statusbar" /> + </div> + </ContribWiring> + </SidebarProvider> + ) +} + +// Referenced type kept for plugin authors' reference (payload shape of +// statusBar.* contributions). +export type { StatusbarItem } diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts new file mode 100644 index 000000000000..bef0647a17d5 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -0,0 +1,133 @@ +import { useEffect } from 'react' + +import { refreshActiveProfile } from '@/store/profile' +import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session' + +import type { GatewayRequester } from '../types' + +// Cron sessions are written by a background scheduler tick, messaging turns by +// the background gateway (Telegram, WeChat, Discord, …) — neither signals the +// desktop websocket, so poll the bounded lists while the app is visible. +const CRON_POLL_INTERVAL_MS = 30_000 +const MESSAGING_POLL_INTERVAL_MS = 10_000 +const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000 + +interface BackgroundSyncParams { + activeIsMessaging: boolean + activeSessionId: null | string + freshDraftReady: boolean + gatewayState: string + refreshActiveMessagingTranscript: () => Promise<unknown> | unknown + refreshCronJobs: () => Promise<unknown> | unknown + refreshCurrentModel: (force?: boolean) => Promise<unknown> | unknown + refreshHermesConfig: () => Promise<unknown> | unknown + refreshMessagingSessions: () => Promise<unknown> | unknown + refreshSessions: () => Promise<unknown> | unknown + requestGateway: GatewayRequester +} + +/** Poll a callback while the tab is visible, on `intervalMs`; re-checks on tab + * re-focus. Returns nothing — meant to live inside an effect. */ +function visiblePoll(intervalMs: number, tick: () => void): () => void { + const run = () => { + if (document.visibilityState === 'visible') { + tick() + } + } + + const intervalId = window.setInterval(run, intervalMs) + document.addEventListener('visibilitychange', run) + + return () => { + window.clearInterval(intervalId) + document.removeEventListener('visibilitychange', run) + } +} + +/** + * Keeps app data live while the gateway is open: an on-connect reseed (model / + * profile / sessions + relative-cwd resolution), the cron / messaging / + * open-transcript visibility polls, and the fresh-draft model/config reseed. + * All the "the desktop websocket won't tell us, so poll" logic in one place. + */ +export function useBackgroundSync({ + activeIsMessaging, + activeSessionId, + freshDraftReady, + gatewayState, + refreshActiveMessagingTranscript, + refreshCronJobs, + refreshCurrentModel, + refreshHermesConfig, + refreshMessagingSessions, + refreshSessions, + requestGateway +}: BackgroundSyncParams): void { + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + void refreshCurrentModel() + void refreshActiveProfile() + void refreshSessions() + + // A RELATIVE workspace cwd (config `terminal.cwd: .`) renders as "." in the + // file tree header — resolve it to the backend's absolute path once. + // Session runtime info still overrides later, and never while a session is + // active. + const cwd = $currentCwd.get().trim() + + if (!$activeSessionId.get() && cwd && !/^(\/|[A-Za-z]:[\\/])/.test(cwd)) { + void requestGateway<{ cwd?: string }>('config.get', { key: 'project', cwd }) + .then(info => { + if (info.cwd && !$activeSessionId.get()) { + setCurrentCwd(info.cwd) + } + }) + .catch(() => undefined) + } + }, [gatewayState, refreshCurrentModel, refreshSessions, requestGateway]) + + // Keep the cron-jobs section live without a user action (scheduler ticks in + // the background); re-check on tab re-focus too. + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + return visiblePoll(CRON_POLL_INTERVAL_MS, () => void refreshCronJobs()) + }, [gatewayState, refreshCronJobs]) + + // Keep the messaging-platform session lists live (inbound turns are written + // by the gateway, not the desktop websocket). + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + return visiblePoll(MESSAGING_POLL_INTERVAL_MS, () => void refreshMessagingSessions()) + }, [gatewayState, refreshMessagingSessions]) + + // Only the open messaging transcript needs its own poll — local chats are + // live over the websocket already. + useEffect(() => { + if (gatewayState !== 'open' || !activeIsMessaging) { + return + } + + const dispose = visiblePoll(ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS, () => void refreshActiveMessagingTranscript()) + void refreshActiveMessagingTranscript() + + return dispose + }, [activeIsMessaging, gatewayState, refreshActiveMessagingTranscript]) + + // A fresh new-session draft (gateway open, no active session) re-pulls the + // model + config so the composer pill reflects the profile default. + useEffect(() => { + if (gatewayState === 'open' && !activeSessionId && freshDraftReady) { + void refreshCurrentModel() + void refreshHermesConfig() + } + }, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig]) +} diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts new file mode 100644 index 000000000000..6dd78dfb7f8d --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -0,0 +1,176 @@ +import { useEffect, useRef } from 'react' + +import { closeActiveTab } from '@/app/chat/close-tab' +import { storedSessionIdForNotification } from '@/lib/session-ids' +import { respondToApprovalAction } from '@/store/native-notifications' +import { + getRememberedRoute, + getRememberedSessionId, + setRememberedRoute, + setRememberedSessionId +} from '@/store/session' +import { onSessionsChanged } from '@/store/session-sync' +import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates' +import { isSecondaryWindow } from '@/store/windows' + +import { requestComposerFocus, requestComposerInsert } from '../../chat/composer/focus' +import { appViewForPath, isOverlayView, NEW_CHAT_ROUTE, sessionRoute } from '../../routes' + +interface DesktopIntegrationsParams { + chatOpen: boolean + hasPreview: boolean + locationPathname: string + navigate: (to: string, options?: { replace?: boolean }) => void + refreshSessions: () => Promise<unknown> | unknown + resumeExhaustedSessionId: null | string + routedSessionId: null | string + runtimeIdByStoredSessionId: { readonly current: Map<string, string> } +} + +/** + * All the Electron-main / OS / cross-window integrations the shell listens for: + * update polling, the ⌘W close shortcut, deep links, native-notification + * navigation, preview-shortcut enablement, remembered-session restore, and + * cross-window session-list sync. Kept out of the wiring controller so the + * "talks to the desktop shell" surface reads as one unit. + */ +export function useDesktopIntegrations({ + locationPathname, + navigate, + refreshSessions, + resumeExhaustedSessionId, + routedSessionId, + runtimeIdByStoredSessionId +}: DesktopIntegrationsParams): void { + // Update polling — populates $desktopVersion/$updateStatus, which feed the + // statusbar version pill and the update toasts. Also honors the main + // process's "open updates" menu request. + useEffect(() => { + startUpdatePoller() + const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow()) + + return () => { + unsubscribe?.() + stopUpdatePoller() + } + }, []) + + // The renderer OWNS ⌘W: on macOS the native menu accelerator would else + // close the window, so claim it unconditionally — the menu then routes ⌘W + // to us (close-preview-requested IPC) and we decide tab-vs-window. + useEffect(() => { + window.hermesDesktop?.setPreviewShortcutActive?.(true) + }, []) + + // Remember the open chat (session id for notifications/resume) AND the last + // non-overlay route (a page like /skills, or a session route) so a relaunch + // lands where you were. Overlays (settings/command-center/…) aren't stored — + // you don't want to boot into a modal. + useEffect(() => { + if (routedSessionId) { + setRememberedSessionId(routedSessionId) + } + + if (!isOverlayView(appViewForPath(locationPathname))) { + setRememberedRoute(locationPathname) + } + }, [locationPathname, routedSessionId]) + + const restoredRef = useRef(false) + + // Restore once on cold start — only when the renderer booted at the default + // route (a hidden-then-shown window keeps its own route). Prefer the full + // remembered route (covers pages); fall back to the last session id. + useEffect(() => { + if (restoredRef.current || locationPathname !== NEW_CHAT_ROUTE) { + restoredRef.current = true + + return + } + + restoredRef.current = true + const route = getRememberedRoute() + + if (route && route !== NEW_CHAT_ROUTE && !isOverlayView(appViewForPath(route))) { + navigate(route, { replace: true }) + + return + } + + const last = getRememberedSessionId() + + if (last) { + navigate(sessionRoute(last), { replace: true }) + } + }, [locationPathname, navigate]) + + useEffect(() => { + if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) { + setRememberedSessionId(null) + } + }, [resumeExhaustedSessionId]) + + // Native-notification click -> jump to the session (runtime id translated to + // the stored id the chat route is keyed by); action buttons resolve in place. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => { + if (sessionId) { + navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current))) + } + }) + + return () => unsubscribe?.() + }, [navigate, runtimeIdByStoredSessionId]) + + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => { + void respondToApprovalAction(sessionId ?? null, actionId) + }) + + return () => unsubscribe?.() + }, []) + + // hermes:// deep links -> a reviewable /blueprint command in the composer. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => { + if (!payload || payload.kind !== 'blueprint' || !payload.name) { + return + } + + const slots = Object.entries(payload.params || {}) + .map(([k, v]) => { + const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v + + return `${k}=${sval}` + }) + .join(' ') + + const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}` + requestComposerInsert(command, { mode: 'block', target: 'main' }) + requestComposerFocus('main') + }) + + void window.hermesDesktop?.signalDeepLinkReady?.() + + return () => unsubscribe?.() + }, []) + + // ⌘W via the macOS menu accelerator → close the focused tab; if nothing is + // closeable, fall back to closing the window (so ⌘W still works as the + // OS-standard window close, esp. secondary windows). The Win/Linux keyboard + // path is the `view.closeTab` keybind (use-keybinds), sharing closeActiveTab. + useEffect(() => { + const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(() => void closeActiveTab()) + + return () => unsubscribe?.() + }, []) + + // Another window mutated the shared session list -> re-pull the sidebar. + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + return onSessionsChanged(() => void refreshSessions()) + }, [refreshSessions]) +} diff --git a/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts b/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts new file mode 100644 index 000000000000..b4e0afc0d5e3 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-pet-bridge.ts @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react' + +import { setPetActivity } from '@/store/pet' +import { setPetScale } from '@/store/pet-gallery' +import { + setPetOverlayOpenAppHandler, + setPetOverlayScaleHandler, + setPetOverlaySubmitHandler +} from '@/store/pet-overlay' +import { $attentionSessionIds, $sessions } from '@/store/session' +import { isSecondaryWindow } from '@/store/windows' + +import type { GatewayRequester } from '../types' + +interface PetBridgeParams { + requestGateway: GatewayRequester + resumeSession: (sessionId: string) => Promise<unknown> | unknown + submitText: (text: string) => Promise<unknown> | unknown +} + +/** + * Wires the popped-out pet overlay back into the app: submit a prompt, resize, + * and open the most-recent thread, plus mirroring "a session is awaiting the + * user" into the pet's pose. Handlers register ONCE through refs tracking the + * latest callbacks — re-registering on identity churn leaves a nulled-handler + * window that can drop a submit. Primary window only. + */ +export function usePetBridge({ requestGateway, resumeSession, submitText }: PetBridgeParams): void { + const submitTextRef = useRef(submitText) + submitTextRef.current = submitText + const resumeSessionRef = useRef(resumeSession) + resumeSessionRef.current = resumeSession + const requestGatewayRef = useRef(requestGateway) + requestGatewayRef.current = requestGateway + + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + setPetOverlaySubmitHandler(text => void submitTextRef.current(text)) + // Alt+wheel resize from the popped-out pet — persist through this window's + // gateway (the overlay has none) so it survives restart. + setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale)) + // Mail icon: $sessions is most-recent-first; the pet is global, so "most + // recent" is the right target. + setPetOverlayOpenAppHandler(() => { + const recent = $sessions.get()[0] + + if (recent?.id) { + void resumeSessionRef.current(recent.id) + } + }) + + return () => { + setPetOverlaySubmitHandler(null) + setPetOverlayOpenAppHandler(null) + setPetOverlayScaleHandler(null) + } + }, []) + + // Mirror "a session is blocked on the user" (clarify/approval) into the pet's + // awaitingInput flag so it shows the `waiting` pose. + useEffect(() => { + const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 }) + + sync() + + return $attentionSessionIds.listen(sync) + }, []) +} diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts new file mode 100644 index 000000000000..d7a7e0467272 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts @@ -0,0 +1,108 @@ +import { useEffect } from 'react' + +import { getSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' +import { toChatMessages } from '@/lib/chat-messages' +import { publishSessionState, setSessionTileDelegate } from '@/store/session-states' +import type { SessionResumeResponse } from '@/types/hermes' + +import type { usePromptActions } from '../../session/hooks/use-prompt-actions' +import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache' +import type { GatewayRequester } from '../types' + +type SessionStateCache = ReturnType<typeof useSessionStateCache> + +interface SessionTileDelegateParams { + archiveSession: (storedSessionId: string) => Promise<unknown> + branchStoredSession: (storedSessionId: string) => Promise<unknown> + executeSlashCommand: ReturnType<typeof usePromptActions>['executeSlashCommand'] + removeSession: (storedSessionId: string) => Promise<unknown> + requestGateway: GatewayRequester + runtimeIdByStoredSessionIdRef: SessionStateCache['runtimeIdByStoredSessionIdRef'] + sessionStateByRuntimeIdRef: SessionStateCache['sessionStateByRuntimeIdRef'] + updateSessionState: SessionStateCache['updateSessionState'] +} + +/** + * Publishes the session-tile delegate: resume / submit / interrupt / slash for + * tiled sessions WITHOUT touching the primary view ($activeSessionId / + * $messages stay the main thread's). Resume reuses a live runtime binding when + * one exists (incl. the main thread's own session); a cold tile binds + + * hydrates the cache, which publishSessionState mirrors to the tile. + */ +export function useSessionTileDelegate({ + archiveSession, + branchStoredSession, + executeSlashCommand, + removeSession, + requestGateway, + runtimeIdByStoredSessionIdRef, + sessionStateByRuntimeIdRef, + updateSessionState +}: SessionTileDelegateParams): void { + useEffect(() => { + setSessionTileDelegate({ + archiveSession: async storedSessionId => { + await archiveSession(storedSessionId) + }, + branchSession: async storedSessionId => { + await branchStoredSession(storedSessionId) + }, + deleteSession: async storedSessionId => { + await removeSession(storedSessionId) + }, + executeSlash: async (rawCommand, sessionId) => { + await executeSlashCommand(rawCommand, { sessionId }) + }, + interruptSession: async runtimeId => { + await requestGateway('session.interrupt', { session_id: runtimeId }) + }, + resumeTile: async storedSessionId => { + const existing = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) + const cached = existing ? sessionStateByRuntimeIdRef.current.get(existing) : undefined + + if (existing && cached?.storedSessionId === storedSessionId) { + publishSessionState(existing, cached) + + return existing + } + + const [prefetch, resumed] = await Promise.all([ + getSessionMessages(storedSessionId).catch(() => null), + requestGateway<SessionResumeResponse>('session.resume', { session_id: storedSessionId, cols: 96 }) + ]) + + const runtimeId = resumed?.session_id + + if (!runtimeId) { + throw new Error('resume returned no session id') + } + + updateSessionState( + runtimeId, + state => ({ + ...state, + busy: Boolean(resumed?.info?.running), + messages: + state.messages.length > 0 ? state.messages : toChatMessages(prefetch?.messages ?? resumed?.messages ?? []) + }), + storedSessionId + ) + + return runtimeId + }, + submitToSession: async (runtimeId, text) => { + await requestGateway('prompt.submit', { session_id: runtimeId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + }, + updateSession: (runtimeId, updater) => updateSessionState(runtimeId, updater) + }) + }, [ + archiveSession, + branchStoredSession, + executeSlashCommand, + removeSession, + requestGateway, + runtimeIdByStoredSessionIdRef, + sessionStateByRuntimeIdRef, + updateSessionState + ]) +} diff --git a/apps/desktop/src/app/contrib/index.ts b/apps/desktop/src/app/contrib/index.ts new file mode 100644 index 000000000000..c39e6f3148e4 --- /dev/null +++ b/apps/desktop/src/app/contrib/index.ts @@ -0,0 +1,7 @@ +// The contribution-driven shell package. `controller` registers panes / +// layouts / chrome and mounts the app root; `wiring` is the data controller + +// memoized pane surfaces; `panes` holds the real-data pane bodies + statusbar +// group setters. Only the controller is a public entry (the app root renders +// it); the rest are internal to this directory. +export { ContribController } from './controller' +export { ContribWiring, WiredPane } from './wiring' diff --git a/apps/desktop/src/app/contrib/panes.tsx b/apps/desktop/src/app/contrib/panes.tsx new file mode 100644 index 000000000000..37aaba24c9ad --- /dev/null +++ b/apps/desktop/src/app/contrib/panes.tsx @@ -0,0 +1,207 @@ +/** + * Real-data panes + composable bar items for the contrib root: + * + * - `PreviewRailPane` — the REAL ChatPreviewRail; files-pane clicks feed it. + * - `FilesPane` — real file browser; activating a file opens it in preview. + * - Core statusbar items with LIVE store-backed labels, registered as DATA + * contributions (`area: 'statusBar.left' / 'statusBar.right'`, payload = + * StatusbarItem) — plugins add theirs through the identical call. + */ + +import { useStore } from '@nanostores/react' +import { useQuery } from '@tanstack/react-query' +import { atom } from 'nanostores' +import type { CSSProperties } from 'react' + +import { ChatPreviewRail } from '@/app/chat/right-rail/preview' +import { RightSidebarPane } from '@/app/right-sidebar' +import { ReviewPane } from '@/app/right-sidebar/review' +import type { GroupSetter } from '@/app/shell/group-setter' +import type { StatusbarItem } from '@/app/shell/statusbar-controls' +import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar' +import type { TitlebarTool } from '@/app/shell/titlebar-controls' +import { DecodeText } from '@/components/ui/decode-text' +import { ContribBoundary } from '@/contrib/react/boundary' +import { useContributions } from '@/contrib/react/use-contributions' +import { registry } from '@/contrib/registry' +import { getLogs } from '@/hermes' +import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' +import { cn } from '@/lib/utils' +import { $filePreviewTarget, $previewTarget, setCurrentSessionPreviewTarget } from '@/store/preview' +import { $currentCwd } from '@/store/session' + +// --------------------------------------------------------------------------- +// Logs — live agent-log tail. OPTIONAL chrome: not in any default layout, +// hidden until the ⌘K "Toggle logs" command opens it ($logsOpen). +// --------------------------------------------------------------------------- + +export function LogsPane() { + const { data, error } = useQuery({ + queryKey: ['contrib-logs-tail'], + queryFn: () => getLogs({ lines: 300 }), + refetchInterval: 5000 + }) + + if (error) { + return <div className="p-3 text-xs text-(--ui-text-quaternary)">log unavailable: {String(error)}</div> + } + + if (!data) { + return ( + <div className="grid h-full place-items-center"> + <DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="LOGS" /> + </div> + ) + } + + // No chrome of its own — the zone header (when the user summons it) is the + // pane's only label. Just the tail. + return ( + <pre className="h-full min-h-0 overflow-auto whitespace-pre-wrap break-words p-2.5 font-mono text-[0.66rem] leading-relaxed text-(--ui-text-secondary)"> + {data.lines.join('\n')} + </pre> + ) +} + +// --------------------------------------------------------------------------- +// Preview — the real rail, fed by the files pane +// --------------------------------------------------------------------------- + +/** Preview-server restart handler, provided by the wiring (usePreviewRouting). + * Atom-bridged: this module can't import contrib-wiring (it imports us). */ +export const $restartPreviewServer = atom<((url: string, context?: string) => Promise<string>) | null>(null) + +export function PreviewRailPane() { + const previewTarget = useStore($previewTarget) + const fileTarget = useStore($filePreviewTarget) + const restartPreviewServer = useStore($restartPreviewServer) + + if (!previewTarget && !fileTarget) { + return ( + <div className="grid h-full place-items-center px-4 text-center"> + <div className="flex flex-col items-center gap-1.5"> + <DecodeText className="text-(--ui-text-quaternary)" prefix={1} text="PREVIEW" /> + <span className="text-[0.68rem] text-(--ui-text-quaternary)">click a file in the files pane</span> + </div> + </div> + ) + } + + return ( + // The contrib layout zeroes --titlebar-height (content sits BELOW the + // titlebar, so the real components' clearance padding must collapse) — + // but the rail SIZES its per-file tab strip with that var. Restore the + // real value for this subtree so the tabs always render at full height. + <div + className={cn(ZONE_CONTENT, 'min-h-0 w-full overflow-hidden [&>aside]:pt-0')} + style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties} + > + <ChatPreviewRail onRestartServer={restartPreviewServer ?? undefined} setTitlebarToolGroup={setTitlebarToolGroup} /> + </div> + ) +} + +/** Open a file from the tree in the real preview pipeline. */ +function previewFile(path: string) { + void normalizeOrLocalPreviewTarget(path, $currentCwd.get() || undefined) + .then(target => { + if (target) { + setCurrentSessionPreviewTarget(target, 'file-browser', path) + } + }) + .catch(() => undefined) +} + +// Layout fit for wrapped asides. Edge chrome (borders/shadows) is neutralized +// GLOBALLY by the tree's seam invariant (see LayoutTreeRoot) — only sizing +// and titlebar clearance are per-wrapper concerns. +const ZONE_CONTENT = 'h-full [&>aside]:h-full [&>aside]:w-full [&>aside]:pt-0' + +export function FilesPane() { + return ( + <div className={ZONE_CONTENT}> + <RightSidebarPane onActivateFile={previewFile} onActivateFolder={previewFile} /> + </div> + ) +} + +// --------------------------------------------------------------------------- +// Review — the real git diff pane (⌘G / $reviewOpen) +// --------------------------------------------------------------------------- + +export function ReviewPaneContent() { + const cwd = useStore($currentCwd) + + // Keyed by cwd like DesktopController so switching projects rebuilds the + // diff state instead of showing the previous repo's files. + return ( + <div className={cn(ZONE_CONTENT, 'flex min-h-0 flex-col [&>aside]:min-h-0 [&>aside]:flex-1')}> + <ReviewPane key={cwd || 'no-cwd'} /> + </div> + ) +} + +// --------------------------------------------------------------------------- +// Statusbar composability: plugins contribute DATA items into +// `statusBar.left` / `statusBar.right`; the wiring feeds them into the REAL +// useStatusbarItems as extraLeftItems/extraRightItems. No core filler here — +// the real statusbar owns the core items (model pill, terminal toggle, …). +// --------------------------------------------------------------------------- + +/** Collect statusbar contributions for one side. A `render()` contribution + * becomes a render-item (arbitrary stateful node); otherwise the declarative + * `data` payload is the StatusbarItem. */ +export function useStatusbarContributions(side: 'left' | 'right'): StatusbarItem[] { + const items = useContributions(`statusBar.${side}`) + + return items + .map(c => + c.render + ? ({ + id: c.id, + render: () => <ContribBoundary id={c.id} variant="chip">{c.render!()}</ContribBoundary> + } satisfies StatusbarItem) + : (c.data as StatusbarItem) + ) + .filter(Boolean) +} + +/** Collect TitlebarTool data contributions for one side of the titlebar. */ +export function useTitlebarToolContributions(side: 'left' | 'right'): TitlebarTool[] { + const items = useContributions(`titleBar.tools.${side}`) + + return items.map(c => c.data as TitlebarTool).filter(Boolean) +} + +/** + * Bridge a page's `GroupSetter` extension point (SkillsView, MessagingView, + * ChatPreviewRail, …) into the registry: each call replaces the group's items + * as DATA contributions in `<prefix>.<side>`, so page-owned items flow through + * the same pipe plugins use. Setting an empty list clears the group. + */ +export function registryGroupSetter<T>(prefix: string): GroupSetter<T> { + const disposers = new Map<string, () => void>() + + return (id, items, side = 'right') => { + const key = `${side}:${id}` + + disposers.get(key)?.() + disposers.set( + key, + registry.registerMany( + items.map((item, i) => ({ + id: `${id}-${i}`, + area: `${prefix}.${side}`, + source: 'core', + order: 100 + i, + data: item as object + })) + ) + ) + } +} + +/** The app's page-facing setters — the same `GroupSetter` shape pages already + * take as props, backed by the registry instead of component state. */ +export const setStatusbarItemGroup = registryGroupSetter<StatusbarItem>('statusBar') +export const setTitlebarToolGroup = registryGroupSetter<TitlebarTool>('titleBar.tools') diff --git a/apps/desktop/src/app/contrib/surfaces.tsx b/apps/desktop/src/app/contrib/surfaces.tsx new file mode 100644 index 000000000000..1d3d475fc166 --- /dev/null +++ b/apps/desktop/src/app/contrib/surfaces.tsx @@ -0,0 +1,204 @@ +/** + * Wiring surfaces — each pane is its own memoized component. Every surface + * reads the reactive state it renders from at the leaf (its own atom + * subscriptions) and reaches the controller's callbacks through the stable + * `actions` bag, so a state change scoped to one surface (or a bare + * wiring-controller tick) never re-renders another. This is what keeps the + * layout tree's zones independently rendered — the whole point of the shell. + */ + +import { useStore } from '@nanostores/react' +import { type ComponentProps, lazy, memo, type ReactNode, Suspense, useMemo } from 'react' +import { Navigate, Route, Routes, useParams } from 'react-router-dom' + +import { ContribBoundary } from '@/contrib/react/boundary' +import { useContributions } from '@/contrib/react/use-contributions' +import { $freshDraftReady, $gatewayState } from '@/store/session' + +import { ChatView } from '../chat' +import { ChatSidebar } from '../chat/sidebar' +import { TerminalPaneChrome } from '../right-sidebar/terminal/chrome' +import { contributedRoutes, NEW_CHAT_ROUTE, ROUTES_AREA, sessionRoute } from '../routes' +import { useStatusSnapshot } from '../shell/hooks/use-status-snapshot' +import { useStatusbarItems } from '../shell/hooks/use-statusbar-items' +import { ModelMenuPanel } from '../shell/model-menu-panel' +import { StatusbarControls } from '../shell/statusbar-controls' + +import { setStatusbarItemGroup, useStatusbarContributions } from './panes' +import type { SidebarActions, WiringActions } from './types' + +// Same lazy-view split as DesktopController — pages load on demand. The +// full-page views the workspace route table mounts live here; overlay views +// (agents/settings/…) are the controller's and stay in wiring.tsx. +const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView })) +const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView })) +const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView })) + +export function LegacySessionRedirect() { + const { sessionId } = useParams() + + return <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} /> +} + +export const SidebarSurface = memo(function SidebarSurface({ + actions, + currentView +}: { + actions: SidebarActions + currentView: ComponentProps<typeof ChatSidebar>['currentView'] +}) { + return <ChatSidebar currentView={currentView} {...actions} /> +}) + +export const TerminalSurface = memo(function TerminalSurface() { + return ( + <div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-(--ui-editor-surface-background)"> + <TerminalPaneChrome /> + </div> + ) +}) + +/** Owns the statusbar's own data hooks (status snapshot poll, contributed + * items) so its 15s refresh — and any statusbar-only churn — re-renders the + * bar alone, never the chat/sidebar/terminal. */ +export const StatusbarSurface = memo(function StatusbarSurface({ + actions, + agentsOpen, + chatOpen, + commandCenterOpen +}: { + actions: WiringActions + agentsOpen: boolean + chatOpen: boolean + commandCenterOpen: boolean +}) { + const gatewayState = useStore($gatewayState) + const freshDraftReady = useStore($freshDraftReady) + const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, actions.requestGateway) + const extraLeftItems = useStatusbarContributions('left') + const extraRightItems = useStatusbarContributions('right') + + const { leftStatusbarItems, statusbarItems } = useStatusbarItems({ + agentsOpen, + chatOpen, + commandCenterOpen, + extraLeftItems, + extraRightItems, + freshDraftReady, + gatewayState, + inferenceStatus, + openAgents: actions.openAgents, + openCommandCenterSection: actions.openCommandCenterSection, + requestGateway: actions.requestGateway, + statusSnapshot, + toggleCommandCenter: actions.toggleCommandCenter + }) + + return <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} /> +}) + +/** The workspace pane: the real route table (chat + full-page views + plugin + * routes). Subscribes to `$gatewayState` and ROUTES_AREA itself; the gateway + * instance + voice cap arrive as props so a reconnect/config load re-renders + * only this surface. ChatView subscribes to its own session atoms, so + * streaming never round-trips through the controller. */ +export const ChatRoutesSurface = memo(function ChatRoutesSurface({ + actions, + maxVoiceRecordingSeconds +}: { + actions: WiringActions + maxVoiceRecordingSeconds?: number +}) { + const gatewayState = useStore($gatewayState) + useContributions(ROUTES_AREA) + const routeContributions = contributedRoutes() + + // Recapture the live gateway instance whenever the connection state flips. + // getGateway reads a controller ref, so gatewayState is the intentional + // re-eval trigger (not a value the computation itself reads). + const gateway = useMemo( + () => actions.getGateway(), + // eslint-disable-next-line react-hooks/exhaustive-deps + [actions, gatewayState] + ) + + const modelMenuContent = useMemo( + () => + gatewayState === 'open' ? ( + <ModelMenuPanel + gateway={gateway || undefined} + onSelectModel={actions.selectModel} + requestGateway={actions.requestGateway} + /> + ) : null, + [actions, gateway, gatewayState] + ) + + const chatView = ( + <ChatView + gateway={gateway} + maxVoiceRecordingSeconds={maxVoiceRecordingSeconds} + modelMenuContent={modelMenuContent} + onAddContextRef={actions.onAddContextRef} + onAddUrl={actions.onAddUrl} + onAttachDroppedItems={actions.onAttachDroppedItems} + onAttachImageBlob={actions.onAttachImageBlob} + onBranchInNewChat={actions.onBranchInNewChat} + onCancel={actions.onCancel} + onDeleteSelectedSession={actions.onDeleteSelectedSession} + onDismissError={actions.onDismissError} + onEdit={actions.onEdit} + onPasteClipboardImage={actions.onPasteClipboardImage} + onPickFiles={actions.onPickFiles} + onPickFolders={actions.onPickFolders} + onPickImages={actions.onPickImages} + onReload={actions.onReload} + onRemoveAttachment={actions.onRemoveAttachment} + onRestoreToMessage={actions.onRestoreToMessage} + onRetryResume={actions.onRetryResume} + onSteer={actions.onSteer} + onSubmit={actions.onSubmit} + onThreadMessagesChange={actions.onThreadMessagesChange} + onToggleSelectedPin={actions.onToggleSelectedPin} + onTranscribeAudio={actions.onTranscribeAudio} + /> + ) + + // FULL-PAGE views (not chat) mark the zone body `data-zone-no-header`: a + // page is not a tab-able surface, so the zone's double-click header toggle + // stands down while one is showing (see onZoneDoubleClick). + const page = (view: ReactNode) => ( + <div className="contents" data-zone-no-header> + <Suspense fallback={null}>{view}</Suspense> + </div> + ) + + return ( + <Routes> + <Route element={chatView} index /> + <Route element={chatView} path=":sessionId" /> + <Route element={page(<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="skills" /> + <Route element={page(<MessagingView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="messaging" /> + <Route element={page(<ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="artifacts" /> + <Route element={null} path="agents" /> + <Route element={null} path="command-center" /> + <Route element={null} path="cron" /> + <Route element={null} path="profiles" /> + <Route element={null} path="settings" /> + <Route element={null} path="starmap" /> + {/* Registry-contributed pages (core features + plugins) render in the + workspace pane like any built-in view — behind the same blast wall + as every other contribution mount. */} + {routeContributions.map(route => ( + <Route + element={page(<ContribBoundary id={route.key}>{route.render()}</ContribBoundary>)} + key={route.key} + path={route.path.slice(1)} + /> + ))} + <Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" /> + <Route element={<LegacySessionRedirect />} path="sessions/:sessionId" /> + <Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" /> + </Routes> + ) +}) diff --git a/apps/desktop/src/app/contrib/types.ts b/apps/desktop/src/app/contrib/types.ts new file mode 100644 index 000000000000..1e2c60ac776a --- /dev/null +++ b/apps/desktop/src/app/contrib/types.ts @@ -0,0 +1,79 @@ +import type { ComponentProps, ReactNode } from 'react' + +import type { ChatView } from '../chat' +import type { ChatSidebar } from '../chat/sidebar' +import type { CommandCenterSection } from '../command-center' +import type { useGatewayRequest } from '../gateway/hooks/use-gateway-request' +import type { ModelMenuPanel } from '../shell/model-menu-panel' + +export type GatewayRequester = ReturnType<typeof useGatewayRequest>['requestGateway'] + +/** The ChatSidebar handlers the controller owns — forwarded verbatim. */ +export type SidebarActions = Pick< + ComponentProps<typeof ChatSidebar>, + | 'onArchiveSession' + | 'onBranchSession' + | 'onDeleteSession' + | 'onLoadMoreMessaging' + | 'onLoadMoreProfileSessions' + | 'onLoadMoreSessions' + | 'onManageCronJob' + | 'onNavigate' + | 'onNewSessionInWorkspace' + | 'onNewSessionSplit' + | 'onResumeSession' + | 'onTriggerCronJob' +> + +/** The ChatView handlers the controller owns — forwarded verbatim. */ +export type ChatActions = Pick< + ComponentProps<typeof ChatView>, + | 'onAddContextRef' + | 'onAddUrl' + | 'onAttachDroppedItems' + | 'onAttachImageBlob' + | 'onBranchInNewChat' + | 'onCancel' + | 'onDeleteSelectedSession' + | 'onDismissError' + | 'onEdit' + | 'onPasteClipboardImage' + | 'onPickFiles' + | 'onPickFolders' + | 'onPickImages' + | 'onReload' + | 'onRemoveAttachment' + | 'onRestoreToMessage' + | 'onRetryResume' + | 'onSteer' + | 'onSubmit' + | 'onThreadMessagesChange' + | 'onToggleSelectedPin' + | 'onTranscribeAudio' +> + +/** + * The complete controller-owned callback surface. One object, one stable + * identity for the app's life — its fields are mutated in place each render, + * so surfaces bound to it never re-render on identity churn but always invoke + * the latest closure. + */ +export interface WiringActions extends SidebarActions, ChatActions { + /** The live gateway instance (held in a controller ref). Surfaces recapture + * it by subscribing to `$gatewayState`, so no gateway prop needs threading. */ + getGateway: () => ComponentProps<typeof ChatView>['gateway'] + openAgents: () => void + openCommandCenterSection: (section: CommandCenterSection) => void + requestGateway: GatewayRequester + selectModel: ComponentProps<typeof ModelMenuPanel>['onSelectModel'] + toggleCommandCenter: () => void +} + +/** The four wired surfaces the controller publishes; `WiredPane` renders one by + * key inside a registered pane / chrome slot. */ +export interface WiringApi { + sidebar: ReactNode + chatRoutes: ReactNode + terminal: ReactNode + statusbar: ReactNode +} diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx new file mode 100644 index 000000000000..ca7a043a9164 --- /dev/null +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -0,0 +1,946 @@ +/** + * Real-featureset wiring for the contrib (layout tree) root — the minimal + * subset of DesktopController's hook chain that makes the REAL surfaces work: + * gateway boot -> sessions list -> click-to-resume -> live transcript -> + * composer send, plus the real terminal. + * + * The wired nodes (sidebar / chat routes / terminal) are exposed through + * context; registered panes render `<WiredPane part="…"/>` to consume them. + */ + +import { useStore } from '@nanostores/react' +import { useQueryClient } from '@tanstack/react-query' +import { type CSSProperties, lazy, type ReactNode, Suspense, useCallback, useEffect, useMemo, useRef } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +import { formatRefValue } from '@/components/assistant-ui/directive-text' +import { BootFailureOverlay } from '@/components/boot-failure-overlay' +import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' +import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' +import { NotificationStack } from '@/components/notifications' +import { DesktopOnboardingOverlay } from '@/components/onboarding' +import { FloatingPet } from '@/components/pet/floating-pet' +import { RemoteDisplayBanner } from '@/components/remote-display-banner' +import { emitGatewayEvent } from '@/contrib/events' +import { getSessionMessages, triggerCronJob } from '@/hermes' +import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' +import { sessionMessagesSignature } from '@/lib/session-signatures' +import { isMessagingSource } from '@/lib/session-source' +import { latestSessionTodos } from '@/lib/todos' +import { setCronFocusJobId } from '@/store/cron' +import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' +import { $filePreviewTarget, $previewTarget } from '@/store/preview' +import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile' +import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects' +import { + $activeSessionId, + $connection, + $currentCwd, + $freshDraftReady, + $gatewayState, + $messages, + $messagingSessions, + $resumeExhaustedSessionId, + $resumeFailedSessionId, + $selectedStoredSessionId, + $sessions, + sessionMatchesStoredId, + sessionPinId, + setAwaitingResponse, + setBusy, + setCurrentBranch, + setCurrentCwd, + setCurrentModel, + setCurrentProvider, + setMessages +} from '@/store/session' +import { focusOpenSession } from '@/store/session-states' +import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos' +import { isSecondaryWindow } from '@/store/windows' +import { useSkinCommand } from '@/themes/use-skin-command' + +import { requestComposerInsert } from '../chat/composer/focus' +import { useComposerActions } from '../chat/hooks/use-composer-actions' +import { CommandPalette } from '../command-palette' +import { useGatewayBoot } from '../gateway/hooks/use-gateway-boot' +import { useGatewayRequest } from '../gateway/hooks/use-gateway-request' +import { useKeybinds } from '../hooks/use-keybinds' +import { ModelPickerOverlay } from '../model-picker-overlay' +import { ModelVisibilityOverlay } from '../model-visibility-overlay' +import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay' +import { FileActionDialogs } from '../right-sidebar/file-actions' +import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker' +import { PersistentTerminal } from '../right-sidebar/terminal/persistent' +import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes' +import { SessionPickerOverlay } from '../session-picker-overlay' +import { SessionSwitcher } from '../session-switcher' +import { useContextSuggestions } from '../session/hooks/use-context-suggestions' +import { useCwdActions } from '../session/hooks/use-cwd-actions' +import { useHermesConfig } from '../session/hooks/use-hermes-config' +import { useMessageStream } from '../session/hooks/use-message-stream' +import { useModelControls } from '../session/hooks/use-model-controls' +import { usePreviewRouting } from '../session/hooks/use-preview-routing' +import { usePromptActions } from '../session/hooks/use-prompt-actions' +import { useRouteResume } from '../session/hooks/use-route-resume' +import { useSessionActions } from '../session/hooks/use-session-actions' +import { useSessionListActions } from '../session/hooks/use-session-list-actions' +import { useSessionStateCache } from '../session/hooks/use-session-state-cache' +import { useOverlayRouting } from '../shell/hooks/use-overlay-routing' +import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width' +import { KeybindPanel } from '../shell/keybind-panel' +import { titlebarControlsPosition } from '../shell/titlebar' +import { TitlebarControls } from '../shell/titlebar-controls' +import { UpdatesOverlay } from '../updates-overlay' + +import { ContribWiringContext } from './context' +import { useBackgroundSync } from './hooks/use-background-sync' +import { useDesktopIntegrations } from './hooks/use-desktop-integrations' +import { usePetBridge } from './hooks/use-pet-bridge' +import { useSessionTileDelegate } from './hooks/use-session-tile-delegate' +import { $restartPreviewServer, useTitlebarToolContributions } from './panes' +import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces' +import type { WiringActions, WiringApi } from './types' + +// Overlay views the controller mounts over the shell — lazy, load on demand. +// The workspace-route full-page views (skills/messaging/artifacts) are the +// ChatRoutesSurface's and live in ./surfaces. +const AgentsView = lazy(async () => ({ default: (await import('../agents')).AgentsView })) +const CommandCenterView = lazy(async () => ({ default: (await import('../command-center')).CommandCenterView })) +const CronView = lazy(async () => ({ default: (await import('../cron')).CronView })) +const ProfilesView = lazy(async () => ({ default: (await import('../profiles')).ProfilesView })) +const SettingsView = lazy(async () => ({ default: (await import('../settings')).SettingsView })) +const StarmapView = lazy(async () => ({ default: (await import('../starmap')).StarmapView })) + +// Surfaces (the four wired panes), the render context + WiredPane, and the +// WiringActions/WiringApi contracts all live in sibling modules — this file is +// the controller that assembles them. +export { WiredPane } from './context' + +export function ContribWiring({ children }: { children: ReactNode }) { + const queryClient = useQueryClient() + const location = useLocation() + const navigate = useNavigate() + + const busyRef = useRef(false) + const creatingSessionRef = useRef(false) + const messagingTranscriptSignatureRef = useRef(new Map<string, string>()) + // Stable identity for the whole callback surface (see WiringActions). Mutated + // in place each render so memoized surfaces never re-render on churn. + const actionsRef = useRef<WiringActions | null>(null) + + const gatewayState = useStore($gatewayState) + const activeSessionId = useStore($activeSessionId) + const currentCwd = useStore($currentCwd) + const freshDraftReady = useStore($freshDraftReady) + const resumeFailedSessionId = useStore($resumeFailedSessionId) + const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) + const selectedStoredSessionId = useStore($selectedStoredSessionId) + const messagingSessions = useStore($messagingSessions) + const profileScope = useStore($profileScope) + + const routedSessionId = routeSessionId(location.pathname) + const routeToken = `${location.pathname}:${location.search}:${location.hash}` + const routeTokenRef = useRef(routeToken) + routeTokenRef.current = routeToken + const getRouteToken = useCallback(() => routeTokenRef.current, []) + + // Mirror "the workspace is showing a full page" into its atom — the + // workspace pane contribution re-registers headerVeto from it, so the main + // zone's tab bar stands down on pages (and returns with the chat). + useEffect(() => { + syncWorkspaceIsPage(location.pathname) + }, [location.pathname]) + + const { + agentsOpen, + chatOpen, + closeOverlayToPreviousRoute, + commandCenterInitialSection, + commandCenterOpen, + cronOpen, + currentView, + openAgents, + openCommandCenterSection, + openStarmap, + profilesOpen, + settingsOpen, + starmapOpen, + toggleCommandCenter + } = useOverlayRouting() + + const { + activeSessionIdRef, + ensureSessionState, + resetViewSync, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView, + updateSessionState + } = useSessionStateCache({ + activeSessionId, + busyRef, + selectedStoredSessionId, + setAwaitingResponse, + setBusy, + setMessages + }) + + const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest() + + const { + loadMoreMessagingForPlatform, + loadMoreSessions, + loadMoreSessionsForProfile, + refreshCronJobs, + refreshMessagingSessions, + refreshSessions + } = useSessionListActions({ profileScope }) + + const updateActiveSessionRuntimeInfo = useCallback( + (info: { branch?: string; cwd?: string }) => { + const sessionId = activeSessionIdRef.current + + if (!sessionId) { + return + } + + updateSessionState(sessionId, state => ({ + ...state, + branch: info.branch ?? state.branch, + cwd: info.cwd ?? state.cwd + })) + }, + [activeSessionIdRef, updateSessionState] + ) + + const { refreshProjectBranch } = useCwdActions({ + activeSessionId, + activeSessionIdRef, + onSessionRuntimeInfo: updateActiveSessionRuntimeInfo, + requestGateway + }) + + const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ + activeSessionIdRef, + refreshProjectBranch + }) + + const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({ + activeSessionId, + queryClient, + requestGateway + }) + + const openProviderSettings = useCallback(() => navigate(`${SETTINGS_ROUTE}?tab=providers`), [navigate]) + + // Post-turn rehydrate from stored history (same behavior as DesktopController, + // including finished-todos restoration). + const hydrateFromStoredSession = useCallback( + async ( + attempts = 1, + storedSessionId = selectedStoredSessionIdRef.current, + runtimeSessionId = activeSessionIdRef.current + ) => { + if (!storedSessionId || !runtimeSessionId) { + return + } + + const storedProfile = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))?.profile + + for (let index = 0; index < Math.max(1, attempts); index += 1) { + try { + const latest = await getSessionMessages(storedSessionId, storedProfile) + const messages = toChatMessages(latest.messages) + updateSessionState( + runtimeSessionId, + state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }), + storedSessionId + ) + + const restored = todosForHydration(latestSessionTodos(messages)) + + if (restored) { + setSessionTodos(runtimeSessionId, restored) + } else { + clearSessionTodos(runtimeSessionId) + } + + return + } catch { + // Best-effort fallback when live stream payloads are empty. + } + + if (index < attempts - 1) { + await new Promise(resolve => window.setTimeout(resolve, 250)) + } + } + }, + [activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState] + ) + + // Refresh the open messaging transcript (inbound platform turns arrive via + // the background gateway, not the desktop websocket). Signature-gated so a + // no-change poll doesn't churn the thread. + const refreshActiveMessagingTranscript = useCallback(async () => { + const storedSessionId = selectedStoredSessionIdRef.current + const runtimeSessionId = activeSessionIdRef.current + + if (!storedSessionId || !runtimeSessionId || busyRef.current) { + return + } + + const stored = $messagingSessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) + + if (!stored || !isMessagingSource(stored.source)) { + return + } + + try { + const latest = await getSessionMessages(storedSessionId, stored.profile) + const signatureKey = `${stored.profile ?? 'default'}:${storedSessionId}` + const sig = sessionMessagesSignature(latest.messages) + + if (messagingTranscriptSignatureRef.current.get(signatureKey) === sig) { + return + } + + messagingTranscriptSignatureRef.current.set(signatureKey, sig) + const messages = toChatMessages(latest.messages) + + updateSessionState( + runtimeSessionId, + state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }), + storedSessionId + ) + } catch { + // Non-fatal: next poll or manual refresh can hydrate. + } + }, [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState]) + + const { handleGatewayEvent } = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession, + queryClient, + refreshHermesConfig, + refreshSessions, + sessionStateByRuntimeIdRef, + updateSessionState + }) + + // Agent-driven preview routing (agent opens a URL/file -> the preview rail + // follows) + the preview server restart handler, layered over the base + // gateway event stream exactly like DesktopController. + const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({ + activeSessionIdRef, + baseHandleGatewayEvent: handleGatewayEvent, + currentCwd, + currentView, + requestGateway, + routedSessionId, + selectedStoredSessionId + }) + + // Composer @-mention context suggestions (files/dirs under the cwd). + useContextSuggestions({ + activeSessionId, + activeSessionIdRef, + currentCwd, + gatewayState, + requestGateway + }) + + // Expose the restart handler to the preview pane contribution (module + // boundary crossed via atom — contrib-panes can't import this file). + useEffect(() => { + $restartPreviewServer.set(restartPreviewServer) + + return () => $restartPreviewServer.set(null) + }, [restartPreviewServer]) + + const { + archiveSession, + branchCurrentSession, + branchStoredSession, + createBackendSessionForSend, + openNewSessionTile, + removeSession, + resumeSession, + selectSidebarItem, + startFreshSessionDraft + } = useSessionActions({ + activeSessionId, + activeSessionIdRef, + busyRef, + creatingSessionRef, + ensureSessionState, + getRouteToken, + navigate, + requestGateway, + resetViewSync, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + syncSessionStateToView, + updateSessionState + }) + + // A profile switch/create drops to a fresh new-session draft so the + // previously open session doesn't bleed across contexts. Skip initial value. + const freshSessionRequest = useStore($freshSessionRequest) + const lastFreshRef = useRef(freshSessionRequest) + + useEffect(() => { + if (freshSessionRequest === lastFreshRef.current) { + return + } + + lastFreshRef.current = freshSessionRequest + startFreshSessionDraft() + }, [freshSessionRequest, startFreshSessionDraft]) + + // Swapping the live gateway to another profile must re-pull that profile's + // global model + active-profile pill (both are nanostores — the blanket + // invalidateQueries on swap doesn't touch them). + const activeGatewayProfile = useStore($activeGatewayProfile) + const lastGatewayProfileRef = useRef(activeGatewayProfile) + + useEffect(() => { + if (activeGatewayProfile === lastGatewayProfileRef.current) { + return + } + + lastGatewayProfileRef.current = activeGatewayProfile + // Force: the new profile has its own default, so reseed even if the + // composer already shows the previous profile's model. + void refreshCurrentModel(true) + void refreshActiveProfile() + }, [activeGatewayProfile, refreshCurrentModel]) + + // New session anchored to a workspace (sidebar "+" on a project/worktree). + // Seeds cwd + branch from the clicked workspace; an explicit worktree path + // also drills the sidebar into that project so the new lane is visible. + const startSessionInWorkspace = useCallback( + (path: null | string) => { + startFreshSessionDraft() + + // A worktree lane carries its own path; the trunk "+" can be path-less + // (the main checkout is implicit), so fall back to the active project's + // root instead of no-op'ing on null. + const target = path?.trim() || resolveNewSessionCwd() + + if (!target) { + return + } + + setCurrentCwd(target) + void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) + .then(info => { + const resolved = info.cwd || target + + setCurrentCwd(resolved) + setCurrentBranch(info.branch || '') + + if (path?.trim()) { + restoreWorktree(resolved) + void followActiveSessionCwd(resolved) + } + }) + .catch(() => undefined) + }, + [requestGateway, startFreshSessionDraft] + ) + + // Composer "branch off into a new worktree": open a fresh session anchored + // to the just-created tree, then prefill the task that kicked it off. + const startWorkSessionRequest = useStore($startWorkSessionRequest) + const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0) + + useEffect(() => { + if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) { + return + } + + lastStartWorkTokenRef.current = startWorkSessionRequest.token + startSessionInWorkspace(startWorkSessionRequest.path) + + if (startWorkSessionRequest.draft) { + requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' }) + } + }, [startSessionInWorkspace, startWorkSessionRequest]) + + const composer = useComposerActions({ activeSessionId, currentCwd, requestGateway }) + + const branchInNewChat = useCallback( + async (messageId?: string) => { + const branched = await branchCurrentSession(messageId) + + if (branched) { + await refreshSessions().catch(() => undefined) + } + + return branched + }, + [branchCurrentSession, refreshSessions] + ) + + const handleSkinCommand = useSkinCommand() + + const { + cancelRun, + editMessage, + executeSlashCommand, + handleThreadMessagesChange, + reloadFromMessage, + restoreToMessage, + steerPrompt, + submitText, + transcribeVoiceAudio + } = usePromptActions({ + activeSessionId, + activeSessionIdRef, + branchCurrentSession: branchInNewChat, + busyRef, + createBackendSessionForSend, + getRouteToken, + handleSkinCommand, + openMemoryGraph: openStarmap, + refreshSessions, + requestGateway, + resumeStoredSession: resumeSession, + selectedStoredSessionIdRef, + startFreshSessionDraft, + sttEnabled, + updateSessionState + }) + + // Session-tile delegate (resume/submit/interrupt/slash + the session verbs + // the tile TAB menu needs, without touching the primary view). + useSessionTileDelegate({ + archiveSession, + branchStoredSession, + executeSlashCommand, + removeSession, + requestGateway, + runtimeIdByStoredSessionIdRef, + sessionStateByRuntimeIdRef, + updateSessionState + }) + + // The popped-out pet overlay's bridge back into the app. + usePetBridge({ requestGateway, resumeSession, submitText }) + + // Clear a failed turn's red error banner. Errors are renderer-local (never + // persisted): a bare error placeholder is dropped entirely; a partial-output + // failure keeps its content and sheds the error. Both the runtime cache AND + // the live $messages view must be updated — preserveLocalAssistantErrors + // re-grafts any still-errored view message on the next session.info flush. + const dismissError = useCallback( + (messageId: string) => { + const runtimeSessionId = activeSessionIdRef.current + + if (!runtimeSessionId) { + return + } + + const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] => + messages.flatMap(message => { + if (message.id !== messageId || !message.error) { + return [message] + } + + if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) { + return [] + } + + return [{ ...message, error: undefined, pending: false }] + }) + + // View first: the cache update below triggers a re-sync that reads + // $messages as the error-preservation baseline. + setMessages(clearErrorIn($messages.get())) + + updateSessionState(runtimeSessionId, state => ({ + ...state, + messages: clearErrorIn(state.messages) + })) + }, + [activeSessionIdRef, updateSessionState] + ) + + useRouteResume({ + activeSessionId, + activeSessionIdRef, + creatingSessionRef, + currentView, + freshDraftReady, + gatewayState, + locationPathname: location.pathname, + resumeSession, + resumeFailedSessionId, + resumeExhaustedSessionId, + routedSessionId, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef, + startFreshSessionDraft + }) + + // Plugins hear the stream FIRST (isolated fan-out in contrib/events), then + // the app dispatches as before — a plugin listener can't affect app flow. + const handleGatewayEventWithPlugins = useCallback( + (event: Parameters<typeof handleDesktopGatewayEvent>[0]) => { + emitGatewayEvent(event) + handleDesktopGatewayEvent(event) + }, + [handleDesktopGatewayEvent] + ) + + useGatewayBoot({ + handleGatewayEvent: handleGatewayEventWithPlugins, + onConnectionReady: c => { + connectionRef.current = c + }, + onGatewayReady: g => { + gatewayRef.current = g + }, + refreshHermesConfig, + refreshSessions + }) + + // Only the open messaging transcript needs its own poll — local chats are + // live over the websocket already. + const activeIsMessaging = + !!selectedStoredSessionId && + isMessagingSource(messagingSessions.find(s => sessionMatchesStoredId(s, selectedStoredSessionId))?.source) + + // Keep app data live while the gateway is open (on-connect reseed + the + // cron / messaging / transcript visibility polls + fresh-draft reseed). + useBackgroundSync({ + activeIsMessaging, + activeSessionId, + freshDraftReady, + gatewayState, + refreshActiveMessagingTranscript, + refreshCronJobs, + refreshCurrentModel, + refreshHermesConfig, + refreshMessagingSessions, + refreshSessions, + requestGateway + }) + + // Electron-main / OS / cross-window integrations: update polling, ⌘W close, + // deep links, native-notification nav, preview-shortcut enablement, + // remembered-session restore, and cross-window session-list sync. + const previewTarget = useStore($previewTarget) + const filePreviewTarget = useStore($filePreviewTarget) + + useDesktopIntegrations({ + chatOpen, + hasPreview: Boolean(filePreviewTarget || previewTarget), + locationPathname: location.pathname, + navigate, + refreshSessions, + resumeExhaustedSessionId, + routedSessionId, + runtimeIdByStoredSessionId: runtimeIdByStoredSessionIdRef + }) + + // Pin/unpin the selected session (statusbar keybind + chat header) — pinned + // on the durable lineage-root id so it survives auto-compression. + const toggleSelectedPin = useCallback(() => { + const sessionId = $selectedStoredSessionId.get() + + if (!sessionId) { + return + } + + const session = $sessions.get().find(s => sessionMatchesStoredId(s, sessionId)) + const pinId = session ? sessionPinId(session) : sessionId + + if ($pinnedSessionIds.get().includes(pinId)) { + unpinSession(pinId) + } else { + pinSession(pinId) + } + }, []) + + // Single global listener for every rebindable hotkey plus the on-screen + // keybind editor's capture mode (same as DesktopController). + useKeybinds({ + openNewSessionTab: () => void openNewSessionTile('center'), + startFreshSession: startFreshSessionDraft, + toggleCommandCenter, + toggleSelectedPin + }) + + // The controller's entire callback surface, gathered into the stable + // `actions` bag. `nextActions` is TS-checked against WiringActions each + // render; its fields are copied into the ref object so `actions` keeps one + // identity for the app's life (memoized surfaces don't re-render on churn) + // while every handler still closes over the latest values. + const nextActions: WiringActions = { + onAddContextRef: composer.addContextRefAttachment, + onAddUrl: url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url), + onArchiveSession: sessionId => void archiveSession(sessionId), + onAttachDroppedItems: composer.attachDroppedItems, + onAttachImageBlob: composer.attachImageBlob, + onBranchInNewChat: messageId => void branchInNewChat(messageId), + onBranchSession: sessionId => void branchStoredSession(sessionId), + onCancel: cancelRun, + onDeleteSelectedSession: () => { + const id = $selectedStoredSessionId.get() + + if (id) { + void removeSession(id) + } + }, + onDeleteSession: sessionId => void removeSession(sessionId), + onDismissError: dismissError, + onEdit: editMessage, + onLoadMoreMessaging: loadMoreMessagingForPlatform, + onLoadMoreProfileSessions: loadMoreSessionsForProfile, + onLoadMoreSessions: loadMoreSessions, + onManageCronJob: jobId => { + setCronFocusJobId(jobId) + navigate(CRON_ROUTE) + }, + onNavigate: selectSidebarItem, + onNewSessionInWorkspace: startSessionInWorkspace, + onNewSessionSplit: dir => void openNewSessionTile(dir), + onPasteClipboardImage: opts => composer.pasteClipboardImage(opts), + onPickFiles: () => void composer.pickContextPaths('file'), + onPickFolders: () => void composer.pickContextPaths('folder'), + onPickImages: () => void composer.pickImages(), + onReload: reloadFromMessage, + onRemoveAttachment: id => void composer.removeAttachment(id), + onRestoreToMessage: restoreToMessage, + // Already on screen (open tile, or the main session)? Jump to its tab; + // otherwise load it into main. + onResumeSession: sessionId => { + if (!focusOpenSession(sessionId)) { + navigate(sessionRoute(sessionId)) + } + }, + onRetryResume: sessionId => void resumeSession(sessionId, true), + onSteer: steerPrompt, + onSubmit: submitText, + onThreadMessagesChange: handleThreadMessagesChange, + onToggleSelectedPin: toggleSelectedPin, + onTranscribeAudio: transcribeVoiceAudio, + onTriggerCronJob: jobId => { + void triggerCronJob(jobId) + .then(() => refreshCronJobs()) + .catch(() => undefined) + }, + getGateway: () => gatewayRef.current, + openAgents, + openCommandCenterSection, + requestGateway, + selectModel, + toggleCommandCenter + } + + if (actionsRef.current) { + Object.assign(actionsRef.current, nextActions) + } else { + actionsRef.current = nextActions + } + + const actions = actionsRef.current + + // Each pane node is memoized on ONLY the reactive inputs it truly consumes; + // everything else reaches its surface through `actions` (stable) or the + // surface's own atom subscriptions. A wiring tick that doesn't touch a + // node's keys leaves its element reference intact, so `WiredPane` (memoized) + // bails on that pane subtree — panes render independently of one another. + const sidebarNode = useMemo( + () => <SidebarSurface actions={actions} currentView={currentView} />, + [actions, currentView] + ) + + const terminalNode = useMemo(() => <TerminalSurface />, []) + + const statusbarNode = useMemo( + () => ( + <StatusbarSurface + actions={actions} + agentsOpen={agentsOpen} + chatOpen={chatOpen} + commandCenterOpen={commandCenterOpen} + /> + ), + [actions, agentsOpen, chatOpen, commandCenterOpen] + ) + + // The voice cap changes only on config load; the gateway instance + all + // chat reactivity are subscribed inside ChatRoutesSurface / ChatView. + const chatRoutesNode = useMemo( + () => <ChatRoutesSurface actions={actions} maxVoiceRecordingSeconds={voiceMaxRecordingSeconds} />, + [actions, voiceMaxRecordingSeconds] + ) + + const api = useMemo<WiringApi>( + () => ({ + chatRoutes: chatRoutesNode, + sidebar: sidebarNode, + statusbar: statusbarNode, + terminal: terminalNode + }), + [chatRoutesNode, sidebarNode, statusbarNode, terminalNode] + ) + + // The REAL titlebar tool clusters (sidebar/flip toggles, haptics, keybinds, + // settings gear) — fixed chrome positioned via the same CSS vars AppShell + // sets, computed here from the live connection. Page-registered tools + // (preview's monitor/devtools cluster, …) arrive as registry contributions. + const leftTitlebarTools = useTitlebarToolContributions('left') + const rightTitlebarTools = useTitlebarToolContributions('right') + const connection = useStore($connection) + const controlsPos = titlebarControlsPosition(connection?.windowButtonPosition, Boolean(connection?.isFullscreen)) + // Exact vertical centering: titlebarControlsPosition() returns + // (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2, but TitlebarControls + // also applies a hard translate-y-0.5 (+2px) to its clusters. Cancel that + // constant so cluster center == bar center — measured, not eyeballed. + const controlsTranslateY = 2 + // Windows/WSLg reserve native min/max/close on the right (AppShell parity: + // prefer the live WCO measurement, fall back to the static reservation). + const measuredOverlayWidth = useWindowControlsOverlayWidth() + const nativeOverlayWidth = measuredOverlayWidth ?? connection?.nativeOverlayWidth ?? 0 + const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem' + // Pane-registered tools (preview's monitor/devtools cluster) anchor flush + // against the static system cluster — in the tree layout the titlebar band + // sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply. + const SYSTEM_TOOL_COUNT = 4 + const paneToolCount = rightTitlebarTools.filter(tool => !tool.hidden).length + const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))` + + const titlebarToolsWidth = + paneToolCount > 0 + ? `calc(${systemToolsWidth} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))` + : systemToolsWidth + + return ( + <ContribWiringContext.Provider value={api}> + <div + className="contents" + style={ + { + '--titlebar-controls-left': `${controlsPos.left}px`, + '--titlebar-controls-top': `${controlsPos.top - controlsTranslateY}px`, + '--titlebar-tools-right': titlebarToolsRight, + '--titlebar-tools-width': titlebarToolsWidth, + '--shell-preview-toolbar-gap': systemToolsWidth + } as CSSProperties + } + > + <TitlebarControls + leftTools={leftTitlebarTools} + onOpenSettings={() => navigate(SETTINGS_ROUTE)} + tools={rightTitlebarTools} + /> + {children} + </div> + + {/* The full real overlay set (mirrors DesktopController's `overlays`). */} + <RemoteDisplayBanner /> + {!isSecondaryWindow() && <DesktopInstallOverlay />} + {!isSecondaryWindow() && ( + <DesktopOnboardingOverlay + enabled={gatewayState === 'open'} + onCompleted={() => { + void refreshHermesConfig() + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + requestGateway={requestGateway} + /> + )} + <ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} /> + <SessionPickerOverlay onResume={resumeSession} /> + <ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} /> + <UpdatesOverlay /> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + <CommandPalette /> + <PetGenerateOverlay /> + <SessionSwitcher /> + <FileActionDialogs /> + <RemoteFolderPicker /> + + {settingsOpen && ( + <Suspense fallback={null}> + <SettingsView + gateway={gatewayRef.current} + onClose={closeOverlayToPreviousRoute} + onConfigSaved={() => { + void refreshHermesConfig() + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + onMainModelChanged={(provider, model) => { + setCurrentProvider(provider) + setCurrentModel(model) + updateModelOptionsCache(provider, model, true) + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + }} + /> + </Suspense> + )} + + {commandCenterOpen && ( + <Suspense fallback={null}> + <CommandCenterView + initialSection={commandCenterInitialSection} + onClose={closeOverlayToPreviousRoute} + onDeleteSession={removeSession} + onNavigateRoute={path => navigate(path)} + onOpenSession={sessionId => navigate(sessionRoute(sessionId))} + /> + </Suspense> + )} + + {agentsOpen && ( + <Suspense fallback={null}> + <AgentsView onClose={closeOverlayToPreviousRoute} /> + </Suspense> + )} + + {cronOpen && ( + <Suspense fallback={null}> + <CronView + onClose={closeOverlayToPreviousRoute} + onOpenSession={sessionId => navigate(sessionRoute(sessionId))} + /> + </Suspense> + )} + + {profilesOpen && ( + <Suspense fallback={null}> + <ProfilesView onClose={closeOverlayToPreviousRoute} /> + </Suspense> + )} + + {starmapOpen && ( + <Suspense fallback={null}> + <StarmapView onClose={closeOverlayToPreviousRoute} /> + </Suspense> + )} + + {/* The full hotkey map (⌘/ and the titlebar keyboard button). */} + <KeybindPanel /> + + {/* Toasts above everything. */} + <NotificationStack /> + + {/* Petdex floating mascot — renders nothing unless installed + enabled. */} + <FloatingPet /> + + {/* Single persistent xterm host chasing the terminal pane's slot rect. */} + <PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} /> + </ContribWiringContext.Provider> + ) +} diff --git a/apps/desktop/src/app/cron/cron-job-model.test.ts b/apps/desktop/src/app/cron/cron-job-model.test.ts new file mode 100644 index 000000000000..14873e299877 --- /dev/null +++ b/apps/desktop/src/app/cron/cron-job-model.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model' + +describe('jobIsScriptOnly', () => { + it('is true when no_agent is set and a script is present', () => { + expect(jobIsScriptOnly({ no_agent: true, script: 'echo hi' })).toBe(true) + }) + + it('is false for agent-backed jobs', () => { + expect(jobIsScriptOnly({ no_agent: false, script: 'echo hi' })).toBe(false) + expect(jobIsScriptOnly({ no_agent: true, script: '' })).toBe(false) + expect(jobIsScriptOnly({ no_agent: true, script: null })).toBe(false) + }) +}) + +describe('validateCronEditor', () => { + it('requires prompt and schedule for agent-backed jobs', () => { + expect(validateCronEditor({ prompt: '', schedule: '', scriptOnlyJob: false })).toBe('prompt_and_schedule') + expect(validateCronEditor({ prompt: '', schedule: '0 9 * * *', scriptOnlyJob: false })).toBe('prompt') + expect(validateCronEditor({ prompt: 'go', schedule: '', scriptOnlyJob: false })).toBe('schedule') + }) + + it('allows an empty prompt when editing a script-only job', () => { + expect(validateCronEditor({ prompt: '', schedule: '0 9 * * 1', scriptOnlyJob: true })).toBe(null) + expect(validateCronEditor({ prompt: 'optional note', schedule: '0 9 * * 1', scriptOnlyJob: true })).toBe(null) + }) + + it('still requires schedule for script-only jobs', () => { + expect(validateCronEditor({ prompt: '', schedule: '', scriptOnlyJob: true })).toBe('schedule') + }) +}) + +describe('cronEditorUpdates', () => { + it('omits prompt when saving a script-only job with an empty prompt', () => { + expect( + cronEditorUpdates( + { deliver: 'local', name: 'Weekly', prompt: '', schedule: '0 9 * * 1' }, + { scriptOnlyJob: true } + ) + ).toEqual({ + deliver: 'local', + name: 'Weekly', + schedule: '0 9 * * 1' + }) + }) + + it('includes prompt when the user typed one on a script-only job', () => { + expect( + cronEditorUpdates( + { deliver: 'email', name: 'Weekly', prompt: 'note', schedule: '0 9 * * 1' }, + { scriptOnlyJob: true } + ).prompt + ).toBe('note') + }) +}) diff --git a/apps/desktop/src/app/cron/cron-job-model.ts b/apps/desktop/src/app/cron/cron-job-model.ts new file mode 100644 index 000000000000..38d3be879f70 --- /dev/null +++ b/apps/desktop/src/app/cron/cron-job-model.ts @@ -0,0 +1,62 @@ +import type { CronJob, CronJobUpdates } from '@/types/hermes' + +const asText = (value: unknown): string => (typeof value === 'string' ? value : '') + +/** Script-only cron jobs run a shell script on schedule with no LLM prompt. */ +export function jobIsScriptOnly(job: Pick<CronJob, 'no_agent' | 'script'>): boolean { + return Boolean(job.no_agent) && Boolean(asText(job.script).trim()) +} + +export type CronEditorValidationError = 'prompt' | 'prompt_and_schedule' | 'schedule' + +export interface CronEditorValidationInput { + prompt: string + schedule: string + scriptOnlyJob: boolean +} + +export function validateCronEditor(input: CronEditorValidationInput): CronEditorValidationError | null { + const trimmedPrompt = input.prompt.trim() + const trimmedSchedule = input.schedule.trim() + + if (!trimmedSchedule && !trimmedPrompt && !input.scriptOnlyJob) { + return 'prompt_and_schedule' + } + + if (!trimmedSchedule) { + return 'schedule' + } + + if (!input.scriptOnlyJob && !trimmedPrompt) { + return 'prompt' + } + + return null +} + +export interface CronEditorSaveValues { + deliver: string + name: string + prompt: string + schedule: string +} + +/** Build the API update payload, preserving an empty prompt on script-only jobs. */ +export function cronEditorUpdates( + values: CronEditorSaveValues, + options: { scriptOnlyJob: boolean } +): CronJobUpdates { + const updates: CronJobUpdates = { + deliver: values.deliver, + name: values.name, + schedule: values.schedule.trim() + } + + const trimmedPrompt = values.prompt.trim() + + if (!options.scriptOnlyJob || trimmedPrompt) { + updates.prompt = trimmedPrompt + } + + return updates +} diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index a3d229ac5af5..52869d0f15e4 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -54,6 +54,7 @@ import { } from '../overlays/panel' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' +import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model' import { jobState, jobTitle, STATE_DOT } from './job-state' const DEFAULT_DELIVER = 'local' @@ -396,12 +397,12 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt updateCronJobs(rows => [...rows, created]) notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) }) } else if (editor.mode === 'edit') { - const updated = await updateCronJob(editor.job.id, { - prompt: values.prompt, - schedule: values.schedule, - name: values.name, - deliver: values.deliver - }) + const scriptOnlyJob = jobIsScriptOnly(editor.job) + + const updated = await updateCronJob( + editor.job.id, + cronEditorUpdates(values, { scriptOnlyJob }) + ) updateCronJobs(rows => rows.map(row => (row.id === updated.id ? updated : row))) notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) }) @@ -712,6 +713,7 @@ function CronEditorDialog({ const open = editor.mode !== 'closed' const isEdit = editor.mode === 'edit' const initial = isEdit ? editor.job : null + const scriptOnlyJob = initial ? jobIsScriptOnly(initial) : false const [name, setName] = useState('') const [prompt, setPrompt] = useState('') @@ -755,11 +757,21 @@ function CronEditorDialog({ async function handleSubmit(event: React.FormEvent) { event.preventDefault() - const trimmedPrompt = prompt.trim() - const trimmedSchedule = schedule.trim() - if (!trimmedPrompt || !trimmedSchedule) { - setError(c.promptScheduleRequired) + const validationError = validateCronEditor({ + prompt, + schedule, + scriptOnlyJob + }) + + if (validationError) { + setError( + validationError === 'schedule' + ? c.scheduleRequired + : validationError === 'prompt' + ? c.promptRequired + : c.promptScheduleRequired + ) return } @@ -771,8 +783,8 @@ function CronEditorDialog({ await onSave({ deliver, name: name.trim(), - prompt: trimmedPrompt, - schedule: trimmedSchedule + prompt: prompt.trim(), + schedule: schedule.trim() }) } catch (err) { setError(err instanceof Error ? err.message : c.failedSave) @@ -790,6 +802,12 @@ function CronEditorDialog({ </DialogHeader> <form className="grid gap-4" onSubmit={handleSubmit}> + {scriptOnlyJob && initial && ( + <FieldHint> + {c.scriptOnlyEditHint} <span className="font-mono">{initial.id}</span> + </FieldHint> + )} + <Field htmlFor="cron-name" label={c.nameLabel} optional optionalLabel={c.optional}> <Input autoFocus @@ -800,7 +818,12 @@ function CronEditorDialog({ /> </Field> - <Field htmlFor="cron-prompt" label={c.promptLabel}> + <Field + htmlFor="cron-prompt" + label={c.promptLabel} + optional={scriptOnlyJob} + optionalLabel={c.optional} + > <Textarea className="min-h-24 font-mono" id="cron-prompt" diff --git a/apps/desktop/src/app/desktop-controller-utils.ts b/apps/desktop/src/app/desktop-controller-utils.ts deleted file mode 100644 index 5754d69ef81a..000000000000 --- a/apps/desktop/src/app/desktop-controller-utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { SessionInfo } from '@/hermes' - -// Cheap signature compare so a poll only swaps the atom (and re-renders the -// sidebar) when the visible rows actually changed. -export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean { - if (a.length !== b.length) { - return false - } - - return a.every((session, i) => { - const other = b[i] - - return ( - other != null && - session.id === other.id && - session._lineage_root_id === other._lineage_root_id && - session.title === other.title && - session.source === other.source && - session.profile === other.profile && - session.preview === other.preview && - session.message_count === other.message_count && - session.last_active === other.last_active && - session.ended_at === other.ended_at - ) - }) -} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx deleted file mode 100644 index 4b8c249bced6..000000000000 --- a/apps/desktop/src/app/desktop-controller.tsx +++ /dev/null @@ -1,1368 +0,0 @@ -import { useStore } from '@nanostores/react' -import { useQueryClient } from '@tanstack/react-query' -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react' -import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom' - -import { BootFailureOverlay } from '@/components/boot-failure-overlay' -import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' -import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' -import { DesktopOnboardingOverlay } from '@/components/onboarding' -import { Pane, PaneMain } from '@/components/pane-shell' -import { RemoteDisplayBanner } from '@/components/remote-display-banner' -import { useMediaQuery } from '@/hooks/use-media-query' -import { isFocusWithin } from '@/lib/keybinds/combo' -import { cn } from '@/lib/utils' -import { useSkinCommand } from '@/themes/use-skin-command' - -import { formatRefValue } from '../components/assistant-ui/directive-text' -import { getSessionMessages, type SessionMessage, triggerCronJob } from '../hermes' -import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' -import { storedSessionIdForNotification } from '../lib/session-ids' -import { isMessagingSource } from '../lib/session-source' -import { latestSessionTodos } from '../lib/todos' -import { setCronFocusJobId } from '../store/cron' -import { - $fileBrowserOpen, - $panesFlipped, - $pinnedSessionIds, - FILE_BROWSER_DEFAULT_WIDTH, - FILE_BROWSER_MAX_WIDTH, - FILE_BROWSER_MIN_WIDTH, - pinSession, - PREVIEW_PANE_ID, - restoreWorktree, - setSidebarOverlayMounted, - SIDEBAR_DEFAULT_WIDTH, - SIDEBAR_MAX_WIDTH, - unpinSession -} from '../store/layout' -import { respondToApprovalAction } from '../store/native-notifications' -import { $paneOpen } from '../store/panes' -import { setPetActivity } from '../store/pet' -import { setPetScale } from '../store/pet-gallery' -import { - setPetOverlayOpenAppHandler, - setPetOverlayScaleHandler, - setPetOverlaySubmitHandler -} from '../store/pet-overlay' -import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' -import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '../store/profile' -import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects' -import { $reviewOpen, REVIEW_PANE_ID } from '../store/review' -import { - $activeSessionId, - $attentionSessionIds, - $currentCwd, - $freshDraftReady, - $gatewayState, - $messages, - $messagingSessions, - $resumeExhaustedSessionId, - $resumeFailedSessionId, - $selectedStoredSessionId, - $sessions, - getRememberedSessionId, - sessionPinId, - setAwaitingResponse, - setBusy, - setCurrentBranch, - setCurrentCwd, - setCurrentModel, - setCurrentProvider, - setMessages, - setRememberedSessionId -} from '../store/session' -import { onSessionsChanged } from '../store/session-sync' -import { clearSessionTodos, setSessionTodos, todosForHydration } from '../store/todos' -import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates' -import { isSecondaryWindow } from '../store/windows' - -import { ChatView } from './chat' -import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus' -import { useComposerActions } from './chat/hooks/use-composer-actions' -import { - ChatPreviewRail, - PREVIEW_RAIL_MAX_WIDTH, - PREVIEW_RAIL_MIN_WIDTH, - PREVIEW_RAIL_PANE_WIDTH -} from './chat/right-rail' -import { ChatSidebar } from './chat/sidebar' -import { CommandPalette } from './command-palette' -import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' -import { useGatewayRequest } from './gateway/hooks/use-gateway-request' -import { useKeybinds } from './hooks/use-keybinds' -import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants' -import { ModelPickerOverlay } from './model-picker-overlay' -import { ModelVisibilityOverlay } from './model-visibility-overlay' -import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay' -import { RightSidebarPane } from './right-sidebar' -import { FileActionDialogs } from './right-sidebar/file-actions' -import { RemoteFolderPicker } from './right-sidebar/files/remote-picker' -import { ReviewPane } from './right-sidebar/review' -import { $terminalTakeover } from './right-sidebar/store' -import { TerminalPaneChrome } from './right-sidebar/terminal/chrome' -import { PersistentTerminal } from './right-sidebar/terminal/persistent' -import { closeActiveTerminal } from './right-sidebar/terminal/terminals' -import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes' -import { SessionPickerOverlay } from './session-picker-overlay' -import { SessionSwitcher } from './session-switcher' -import { useContextSuggestions } from './session/hooks/use-context-suggestions' -import { useCwdActions } from './session/hooks/use-cwd-actions' -import { useHermesConfig } from './session/hooks/use-hermes-config' -import { useMessageStream } from './session/hooks/use-message-stream' -import { useModelControls } from './session/hooks/use-model-controls' -import { usePreviewRouting } from './session/hooks/use-preview-routing' -import { usePromptActions } from './session/hooks/use-prompt-actions' -import { useRouteResume } from './session/hooks/use-route-resume' -import { useSessionActions } from './session/hooks/use-session-actions' -import { useSessionListActions } from './session/hooks/use-session-list-actions' -import { useSessionStateCache } from './session/hooks/use-session-state-cache' -import { AppShell } from './shell/app-shell' -import { useOverlayRouting } from './shell/hooks/use-overlay-routing' -import { useStatusSnapshot } from './shell/hooks/use-status-snapshot' -import { useStatusbarItems } from './shell/hooks/use-statusbar-items' -import { ModelMenuPanel } from './shell/model-menu-panel' -import type { StatusbarItem } from './shell/statusbar-controls' -import type { TitlebarTool } from './shell/titlebar-controls' -import { useGroupRegistry } from './shell/use-group-registry' -import { UpdatesOverlay } from './updates-overlay' - -const AgentsView = lazy(async () => ({ default: (await import('./agents')).AgentsView })) -const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView })) -const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView })) -const CronView = lazy(async () => ({ default: (await import('./cron')).CronView })) -const StarmapView = lazy(async () => ({ default: (await import('./starmap')).StarmapView })) -const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView })) -const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView })) -const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView })) -const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView })) - -// Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The -// Cron sessions are written by a background scheduler tick (the desktop -// backend), so no user action signals the UI. Poll the bounded cron list on -// this cadence while the app is open + visible so new runs surface promptly -// instead of waiting for the next user-triggered refreshSessions(). -const CRON_POLL_INTERVAL_MS = 30_000 -// Messaging-platform turns are written by the background gateway (WeChat, -// Telegram, Discord, …), not the desktop websocket that drives local chats. -// Poll the bounded messaging slice while visible so inbound platform traffic -// appears without requiring a manual refresh or route change. -const MESSAGING_POLL_INTERVAL_MS = 10_000 -const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000 - -function sessionMatchesStoredId(session: { id: string; _lineage_root_id?: null | string }, id: string): boolean { - return session.id === id || session._lineage_root_id === id -} - -function hashString(hash: number, value: string): number { - let next = hash - - for (let i = 0; i < value.length; i++) { - next ^= value.charCodeAt(i) - next = Math.imul(next, 16777619) - } - - return next >>> 0 -} - -function sessionMessagesSignature(messages: SessionMessage[]): string { - let hash = 2166136261 - - for (const m of messages) { - hash = hashString(hash, m.role) - hash = hashString(hash, String(m.timestamp ?? '')) - hash = hashString(hash, typeof m.content === 'string' ? m.content : (JSON.stringify(m.content) ?? '')) - } - - return `${messages.length}:${hash}` -} - -export function DesktopController() { - const queryClient = useQueryClient() - const location = useLocation() - const navigate = useNavigate() - - const busyRef = useRef(false) - const creatingSessionRef = useRef(false) - const messagingTranscriptSignatureRef = useRef(new Map<string, string>()) - - const gatewayState = useStore($gatewayState) - const activeSessionId = useStore($activeSessionId) - const currentCwd = useStore($currentCwd) - const freshDraftReady = useStore($freshDraftReady) - const resumeFailedSessionId = useStore($resumeFailedSessionId) - const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) - const filePreviewTarget = useStore($filePreviewTarget) - const previewTarget = useStore($previewTarget) - const selectedStoredSessionId = useStore($selectedStoredSessionId) - const messagingSessions = useStore($messagingSessions) - const terminalTakeover = useStore($terminalTakeover) - const reviewOpen = useStore($reviewOpen) - const fileBrowserOpen = useStore($fileBrowserOpen) - const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID)) - const panesFlipped = useStore($panesFlipped) - const profileScope = useStore($profileScope) - // Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail — - // collapse both sidebars (without touching their stored open state) so the - // hover-reveal overlay becomes the way in. Restores once it's wide again. - const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY) - - const routedSessionId = routeSessionId(location.pathname) - const routeToken = `${location.pathname}:${location.search}:${location.hash}` - const routeTokenRef = useRef(routeToken) - routeTokenRef.current = routeToken - const getRouteToken = useCallback(() => routeTokenRef.current, []) - - const { - agentsOpen, - chatOpen, - closeOverlayToPreviousRoute, - commandCenterInitialSection, - commandCenterOpen, - cronOpen, - currentView, - openAgents, - openCommandCenterSection, - openStarmap, - profilesOpen, - settingsOpen, - starmapOpen, - toggleCommandCenter - } = useOverlayRouting() - - const terminalSidebarOpen = chatOpen && terminalTakeover - - const titlebarToolGroups = useGroupRegistry<TitlebarTool>() - const statusbarItemGroups = useGroupRegistry<StatusbarItem>() - const setTitlebarToolGroup = titlebarToolGroups.set - const setStatusbarItemGroup = statusbarItemGroups.set - - const { - activeSessionIdRef, - ensureSessionState, - runtimeIdByStoredSessionIdRef, - selectedStoredSessionIdRef, - sessionStateByRuntimeIdRef, - syncSessionStateToView, - updateSessionState - } = useSessionStateCache({ - activeSessionId, - busyRef, - selectedStoredSessionId, - setAwaitingResponse, - setBusy, - setMessages - }) - - const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest() - - useEffect(() => { - window.hermesDesktop?.setPreviewShortcutActive?.(Boolean(chatOpen && (filePreviewTarget || previewTarget))) - }, [chatOpen, filePreviewTarget, previewTarget]) - - useEffect(() => { - startUpdatePoller() - const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow()) - - return () => { - unsubscribe?.() - stopUpdatePoller() - } - }, []) - - // Remember the open chat so a relaunch reopens it instead of an empty new-chat. - useEffect(() => { - if (routedSessionId) { - setRememberedSessionId(routedSessionId) - } - }, [routedSessionId]) - - // Restore that chat once, on cold start only (we're at the new-chat route and - // haven't navigated yet). A dead/deleted id self-clears via the exhausted latch - // below, so we never boot-loop into an error screen. - const restoredLastSessionRef = useRef(false) - useEffect(() => { - if (restoredLastSessionRef.current) { - return - } - - restoredLastSessionRef.current = true - const last = getRememberedSessionId() - - if (last && location.pathname === NEW_CHAT_ROUTE) { - navigate(sessionRoute(last), { replace: true }) - } - }, [location.pathname, navigate]) - - useEffect(() => { - if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) { - setRememberedSessionId(null) - } - }, [resumeExhaustedSessionId]) - - // Notification click: the main process already focused the window; jump to its - // session. Notifications are tagged with the gateway *runtime* session id, but - // the chat route is keyed by the *stored* id — navigating with the runtime id - // resumes a non-existent stored session ("session not found") and strands the - // user. Translate runtime -> stored before navigating. - useEffect(() => { - const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => { - if (sessionId) { - navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionIdRef.current))) - } - }) - - return () => unsubscribe?.() - }, [navigate, runtimeIdByStoredSessionIdRef]) - - // Notification action button (Approve/Reject) — resolve in place, no navigation. - useEffect(() => { - const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => { - void respondToApprovalAction(sessionId ?? null, actionId) - }) - - return () => unsubscribe?.() - }, []) - - // hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint). - // Build the equivalent /blueprint slash command from the payload and drop - // it into the composer — the user reviews/edits, then sends; the agent (or - // the shared command handler) creates the job. Signal readiness so a link - // that arrived during boot is flushed exactly once. - useEffect(() => { - const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => { - if (!payload || payload.kind !== 'blueprint' || !payload.name) { - return - } - - const slots = Object.entries(payload.params || {}) - .map(([k, v]) => { - const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v - - return `${k}=${sval}` - }) - .join(' ') - - const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}` - requestComposerInsert(command, { mode: 'block', target: 'main' }) - requestComposerFocus('main') - }) - - // Tell the main process the renderer is ready to receive deep links. - void window.hermesDesktop?.signalDeepLinkReady?.() - - return () => unsubscribe?.() - }, []) - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.altKey || event.shiftKey || event.key.toLowerCase() !== 'w' || (!event.metaKey && !event.ctrlKey)) { - return - } - - // Terminal focused: ⌘W closes the active terminal. Ctrl+W is left untouched - // for the shell's werase, and nothing else may steal ⌘/Ctrl+W from a - // focused terminal (so it never closes a preview tab out from under it). - if (isFocusWithin('[data-terminal]')) { - if (event.metaKey && !event.ctrlKey) { - event.preventDefault() - event.stopPropagation() - closeActiveTerminal() - } - - return - } - - // Otherwise ⌘/Ctrl+W closes the active preview tab when one is open. - if ($filePreviewTarget.get() || $previewTarget.get()) { - event.preventDefault() - event.stopPropagation() - closeActiveRightRailTab() - } - } - - const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(closeActiveRightRailTab) - - window.addEventListener('keydown', onKeyDown, { capture: true }) - - return () => { - unsubscribe?.() - window.removeEventListener('keydown', onKeyDown, { capture: true }) - } - }, []) - - const { - loadMoreMessagingForPlatform, - loadMoreSessions, - loadMoreSessionsForProfile, - refreshCronJobs, - refreshMessagingSessions, - refreshSessions - } = useSessionListActions({ profileScope }) - - // Another window mutated the shared session list (e.g. a chat started in the - // pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so - // only real windows bother. - useEffect(() => { - if (isSecondaryWindow()) { - return - } - - return onSessionsChanged(() => void refreshSessions().catch(() => undefined)) - }, [refreshSessions]) - - const toggleSelectedPin = useCallback(() => { - const sessionId = $selectedStoredSessionId.get() - - if (!sessionId) { - return - } - - // Pin on the durable lineage-root id so the pin survives auto-compression. - const session = $sessions.get().find(s => s.id === sessionId || s._lineage_root_id === sessionId) - const pinId = session ? sessionPinId(session) : sessionId - - if ($pinnedSessionIds.get().includes(pinId)) { - unpinSession(pinId) - } else { - pinSession(pinId) - } - }, []) - - const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) - - const updateActiveSessionRuntimeInfo = useCallback( - (info: { branch?: string; cwd?: string }) => { - const sessionId = activeSessionIdRef.current - - if (!sessionId) { - return - } - - updateSessionState(sessionId, state => ({ - ...state, - branch: info.branch ?? state.branch, - cwd: info.cwd ?? state.cwd - })) - }, - [activeSessionIdRef, updateSessionState] - ) - - const { refreshProjectBranch } = useCwdActions({ - activeSessionId, - activeSessionIdRef, - onSessionRuntimeInfo: updateActiveSessionRuntimeInfo, - requestGateway - }) - - const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ - activeSessionIdRef, - refreshProjectBranch - }) - - const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({ - activeSessionId, - queryClient, - requestGateway - }) - - const openProviderSettings = useCallback(() => { - navigate(`${SETTINGS_ROUTE}?tab=providers`) - }, [navigate]) - - const modelMenuContent = useMemo( - () => - gatewayState === 'open' ? ( - <ModelMenuPanel - gateway={gatewayRef.current || undefined} - onSelectModel={selectModel} - requestGateway={requestGateway} - /> - ) : null, - [gatewayRef, gatewayState, requestGateway, selectModel] - ) - - useContextSuggestions({ - activeSessionId, - activeSessionIdRef, - currentCwd, - gatewayState, - requestGateway - }) - - const hydrateFromStoredSession = useCallback( - async ( - attempts = 1, - storedSessionId = selectedStoredSessionIdRef.current, - runtimeSessionId = activeSessionIdRef.current - ) => { - if (!storedSessionId || !runtimeSessionId) { - return - } - - const storedProfile = $sessions - .get() - .find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile - - for (let index = 0; index < Math.max(1, attempts); index += 1) { - try { - const latest = await getSessionMessages(storedSessionId, storedProfile) - const messages = toChatMessages(latest.messages) - updateSessionState( - runtimeSessionId, - state => ({ - ...state, - messages: preserveLocalAssistantErrors(messages, state.messages) - }), - storedSessionId - ) - - // Rehydration runs *after* a turn completes, so an "active" stored - // list (last `todo` still pending/in_progress) means the turn ended - // without a final update — it's stale, not in-flight. Re-seeding it - // would re-pin "Tasks N/M" above the composer and undo the turn-end - // clear (and survive restarts, since it's read back from history). - // todosForHydration restores only a *finished* list (its short linger - // shows the last checkmark); anything still active is dropped. - const restored = todosForHydration(latestSessionTodos(messages)) - - if (restored) { - setSessionTodos(runtimeSessionId, restored) - } else { - clearSessionTodos(runtimeSessionId) - } - - return - } catch { - // Best-effort fallback when live stream payloads are empty. - } - - if (index < attempts - 1) { - await new Promise(resolve => window.setTimeout(resolve, 250)) - } - } - }, - [activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState] - ) - - const refreshActiveMessagingTranscript = useCallback(async () => { - const storedSessionId = selectedStoredSessionIdRef.current - const runtimeSessionId = activeSessionIdRef.current - - if (!storedSessionId || !runtimeSessionId || busyRef.current) { - return - } - - const stored = $messagingSessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) - - if (!stored || !isMessagingSource(stored.source)) { - return - } - - try { - const latest = await getSessionMessages(storedSessionId, stored.profile) - const signatureKey = `${stored.profile ?? 'default'}:${storedSessionId}` - const sig = sessionMessagesSignature(latest.messages) - - if (messagingTranscriptSignatureRef.current.get(signatureKey) === sig) { - return - } - - messagingTranscriptSignatureRef.current.set(signatureKey, sig) - const messages = toChatMessages(latest.messages) - - updateSessionState( - runtimeSessionId, - state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }), - storedSessionId - ) - } catch { - // Non-fatal: next poll or manual refresh can hydrate. - } - }, [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState]) - - const { handleGatewayEvent } = useMessageStream({ - activeSessionIdRef, - hydrateFromStoredSession, - queryClient, - refreshHermesConfig, - refreshSessions, - sessionStateByRuntimeIdRef, - updateSessionState - }) - - const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({ - activeSessionIdRef, - baseHandleGatewayEvent: handleGatewayEvent, - currentCwd, - currentView, - requestGateway, - routedSessionId, - selectedStoredSessionId - }) - - const { - archiveSession, - branchCurrentSession, - branchStoredSession, - createBackendSessionForSend, - openSettings, - removeSession, - resumeSession, - selectSidebarItem, - startFreshSessionDraft - } = useSessionActions({ - activeSessionId, - activeSessionIdRef, - busyRef, - creatingSessionRef, - ensureSessionState, - getRouteToken, - navigate, - requestGateway, - runtimeIdByStoredSessionIdRef, - selectedStoredSessionId, - selectedStoredSessionIdRef, - sessionStateByRuntimeIdRef, - syncSessionStateToView, - updateSessionState - }) - - // Single global listener for every rebindable hotkey (incl. profile switching) - // plus the on-screen keybind editor's capture mode. - useKeybinds({ - startFreshSession: startFreshSessionDraft, - toggleCommandCenter, - toggleSelectedPin - }) - - // A profile switch/create drops to a fresh new-session draft so the previously - // open session doesn't bleed across contexts. Skip the initial value. - const freshSessionRequest = useStore($freshSessionRequest) - const lastFreshRef = useRef(freshSessionRequest) - - useEffect(() => { - if (freshSessionRequest === lastFreshRef.current) { - return - } - - lastFreshRef.current = freshSessionRequest - startFreshSessionDraft() - }, [freshSessionRequest, startFreshSessionDraft]) - - // Swapping the live gateway to another profile must re-pull that profile's - // global model + active-profile pill. Both are nanostores, so the blanket - // invalidateQueries() the profile store fires on swap doesn't touch them — - // without this the statusbar keeps showing the previous profile's model - // (the "forgets the LLM setting" report). gatewayState stays 'open' across a - // swap (background sockets persist), so the open→open effect won't re-run. - const activeGatewayProfile = useStore($activeGatewayProfile) - const lastGatewayProfileRef = useRef(activeGatewayProfile) - - useEffect(() => { - if (activeGatewayProfile === lastGatewayProfileRef.current) { - return - } - - lastGatewayProfileRef.current = activeGatewayProfile - // Force: the new profile has its own default, so reseed even if the composer - // already shows the previous profile's model. - void refreshCurrentModel(true) - void refreshActiveProfile() - }, [activeGatewayProfile, refreshCurrentModel]) - - const composer = useComposerActions({ - activeSessionId, - currentCwd, - requestGateway - }) - - const branchInNewChat = useCallback( - async (messageId?: string) => { - const branched = await branchCurrentSession(messageId) - - if (branched) { - await refreshSessions().catch(() => undefined) - } - - return branched - }, - [branchCurrentSession, refreshSessions] - ) - - // Clear a failed turn's red error banner from the transcript. Errors are - // renderer-local state (never persisted), so dismissing is purely a view + - // session-cache edit. A message that errored before emitting any visible - // text is a bare error placeholder → drop it entirely; one that streamed - // partial output then failed keeps its content and just sheds the error. - // Both the per-runtime cache AND the live $messages view must be updated: - // `preserveLocalAssistantErrors` re-grafts any still-errored message it - // finds in the view onto the next session.info flush, so clearing only the - // cache would let the heartbeat resurrect the banner. - const dismissError = useCallback( - (messageId: string) => { - const runtimeSessionId = activeSessionIdRef.current - - if (!runtimeSessionId) { - return - } - - const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] => - messages.flatMap(message => { - if (message.id !== messageId || !message.error) { - return [message] - } - - if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) { - return [] - } - - return [{ ...message, error: undefined, pending: false }] - }) - - // View first: the flush below reads $messages as the "current" baseline - // for error preservation, so the banner must be gone from it before the - // cache update triggers a re-sync. - setMessages(clearErrorIn($messages.get())) - - updateSessionState(runtimeSessionId, state => ({ - ...state, - messages: clearErrorIn(state.messages) - })) - }, - [activeSessionIdRef, updateSessionState] - ) - - const startSessionInWorkspace = useCallback( - (path: null | string) => { - startFreshSessionDraft() - - // A worktree lane carries its own path; the trunk "+" can be path-less (the - // main checkout is implicit), so fall back to the active project's root - // instead of no-op'ing on null — that was "+ on main does nothing". - const target = path?.trim() || resolveNewSessionCwd() - - if (!target) { - return - } - - // The next message creates the backend session in $currentCwd, so seed - // it (and the branch) from the workspace the user clicked the + on. - setCurrentCwd(target) - void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) - .then(info => { - const resolved = info.cwd || target - - setCurrentCwd(resolved) - setCurrentBranch(info.branch || '') - - // An EXPLICIT target (a worktree/lane path — e.g. just-created via - // "convert a branch" / "new worktree") drills the sidebar into that - // project so the new lane is visible at once. Without this, a brand-new - // worktree session is invisible from the all-projects overview (the - // live overlay skips `.worktrees` rows, and the session.info cwd-follow - // only fires on a same-session move, not a fresh session). The - // path-less trunk "+" keeps the current scope untouched. - if (path?.trim()) { - restoreWorktree(resolved) - void followActiveSessionCwd(resolved) - } - }) - .catch(() => undefined) - }, - [requestGateway, startFreshSessionDraft] - ) - - // Composer "branch off into a new worktree": the composer already created the - // worktree and cleared its draft; open a fresh session anchored to that tree, - // then prefill the task that kicked it off. startSessionInWorkspace owns the - // reset+cwd seed (it runs startFreshSessionDraft, which would otherwise stomp - // the cwd back to the default), so the prefill is dispatched right after — its - // deferred event lands once the fresh composer has remounted and rebound. - const startWorkSessionRequest = useStore($startWorkSessionRequest) - const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0) - - useEffect(() => { - if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) { - return - } - - lastStartWorkTokenRef.current = startWorkSessionRequest.token - startSessionInWorkspace(startWorkSessionRequest.path) - - if (startWorkSessionRequest.draft) { - requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' }) - } - }, [startSessionInWorkspace, startWorkSessionRequest]) - - const handleSkinCommand = useSkinCommand() - - const { - cancelRun, - editMessage, - handleThreadMessagesChange, - reloadFromMessage, - restoreToMessage, - steerPrompt, - submitText, - transcribeVoiceAudio - } = usePromptActions({ - activeSessionId, - activeSessionIdRef, - branchCurrentSession: branchInNewChat, - busyRef, - createBackendSessionForSend, - handleSkinCommand, - openMemoryGraph: openStarmap, - refreshSessions, - requestGateway, - resumeStoredSession: resumeSession, - selectedStoredSessionIdRef, - startFreshSessionDraft, - sttEnabled, - updateSessionState - }) - - // The popped-out pet drives two actions back into the app: send a prompt, and - // open the most recent thread. Both are registered ONCE through refs that track - // the latest callbacks — re-registering on every `submitText`/`resumeSession` - // identity change left a brief window where the handler was nulled (cleanup - // before re-register), which could drop a submit fired from the overlay (e.g. - // creating a session from the new-session screen). The ref form keeps a stable, - // always-current handler. Primary window only — it owns the overlay. - const submitTextRef = useRef(submitText) - submitTextRef.current = submitText - const resumeSessionRef = useRef(resumeSession) - resumeSessionRef.current = resumeSession - const requestGatewayRef = useRef(requestGateway) - requestGatewayRef.current = requestGateway - - useEffect(() => { - if (isSecondaryWindow()) { - return - } - - setPetOverlaySubmitHandler(text => void submitTextRef.current(text)) - // Alt+wheel resize from the popped-out pet — persist it through this - // window's gateway (the overlay has none) so it survives restart. - setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale)) - // Mail icon: $sessions is ordered most-recent-first; the pet is global (not - // per session) so "most recent" is the right target. main.cjs already raised - // the window before forwarding this. - setPetOverlayOpenAppHandler(() => { - const recent = $sessions.get()[0] - - if (recent?.id) { - void resumeSessionRef.current(recent.id) - } - }) - - return () => { - setPetOverlaySubmitHandler(null) - setPetOverlayOpenAppHandler(null) - setPetOverlayScaleHandler(null) - } - }, []) - - // Mirror "a session is blocked on the user" (clarify/approval) into the pet's - // awaitingInput flag so it shows the `waiting` pose. Lives on $petActivity so - // it rides the same atom the pop-out overlay mirrors — no session list needed - // there. Every window keeps its own in-window pet in sync. - useEffect(() => { - const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 }) - - sync() - - return $attentionSessionIds.listen(sync) - }, []) - - useGatewayBoot({ - handleGatewayEvent: handleDesktopGatewayEvent, - onConnectionReady: c => { - connectionRef.current = c - }, - onGatewayReady: g => { - gatewayRef.current = g - }, - refreshHermesConfig, - refreshSessions - }) - - useEffect(() => { - if (gatewayState === 'open') { - void refreshCurrentModel() - void refreshActiveProfile() - void refreshSessions().catch(() => undefined) - } - }, [gatewayState, refreshCurrentModel, refreshSessions]) - - // Keep the cron jobs section live without a user action: the scheduler ticks - // in the background (advancing next-run/state and creating runs), so poll the - // job list on an interval (and on tab re-focus) while connected. - useEffect(() => { - if (gatewayState !== 'open') { - return - } - - const tick = () => { - if (document.visibilityState === 'visible') { - void refreshCronJobs() - } - } - - const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS) - document.addEventListener('visibilitychange', tick) - - return () => { - window.clearInterval(intervalId) - document.removeEventListener('visibilitychange', tick) - } - }, [gatewayState, refreshCronJobs]) - - // Keep messaging-platform session lists live: inbound Telegram/WeChat/Discord - // turns are written by the gateway, not the desktop websocket, so they won't - // appear without polling. - useEffect(() => { - if (gatewayState !== 'open') { - return - } - - const tick = () => { - if (document.visibilityState === 'visible') { - void refreshMessagingSessions() - } - } - - const intervalId = window.setInterval(tick, MESSAGING_POLL_INTERVAL_MS) - document.addEventListener('visibilitychange', tick) - - return () => { - window.clearInterval(intervalId) - document.removeEventListener('visibilitychange', tick) - } - }, [gatewayState, refreshMessagingSessions]) - - // Only the open messaging transcript needs a poll — local chats are already - // live over the websocket, so arming a timer for them would just no-op every - // tick. Gate on the active session actually being a messaging source. - const activeIsMessaging = - !!selectedStoredSessionId && - isMessagingSource(messagingSessions.find(s => sessionMatchesStoredId(s, selectedStoredSessionId))?.source) - - // Keep the currently-viewed messaging transcript live. - useEffect(() => { - if (gatewayState !== 'open' || !activeIsMessaging) { - return - } - - const tick = () => { - if (document.visibilityState === 'visible') { - void refreshActiveMessagingTranscript() - } - } - - const intervalId = window.setInterval(tick, ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS) - document.addEventListener('visibilitychange', tick) - tick() - - return () => { - window.clearInterval(intervalId) - document.removeEventListener('visibilitychange', tick) - } - }, [activeIsMessaging, gatewayState, refreshActiveMessagingTranscript]) - - useEffect(() => { - if (gatewayState === 'open' && !activeSessionId && freshDraftReady) { - void refreshCurrentModel() - void refreshHermesConfig() - } - }, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig]) - - useRouteResume({ - activeSessionId, - activeSessionIdRef, - creatingSessionRef, - currentView, - freshDraftReady, - gatewayState, - locationPathname: location.pathname, - resumeSession, - resumeFailedSessionId, - resumeExhaustedSessionId, - routedSessionId, - runtimeIdByStoredSessionIdRef, - selectedStoredSessionId, - selectedStoredSessionIdRef, - startFreshSessionDraft - }) - - const { leftStatusbarItems, statusbarItems } = useStatusbarItems({ - agentsOpen, - chatOpen, - commandCenterOpen, - extraLeftItems: statusbarItemGroups.flat.left, - extraRightItems: statusbarItemGroups.flat.right, - gatewayState, - inferenceStatus, - openAgents, - freshDraftReady, - openCommandCenterSection, - requestGateway, - statusSnapshot, - toggleCommandCenter - }) - - const sidebar = ( - <ChatSidebar - currentView={currentView} - onArchiveSession={sessionId => void archiveSession(sessionId)} - onBranchSession={sessionId => void branchStoredSession(sessionId)} - onDeleteSession={sessionId => void removeSession(sessionId)} - onLoadMoreMessaging={loadMoreMessagingForPlatform} - onLoadMoreProfileSessions={loadMoreSessionsForProfile} - onLoadMoreSessions={loadMoreSessions} - onManageCronJob={jobId => { - setCronFocusJobId(jobId) - navigate(CRON_ROUTE) - }} - onNavigate={selectSidebarItem} - onNewSessionInWorkspace={startSessionInWorkspace} - onResumeSession={sessionId => navigate(sessionRoute(sessionId))} - onTriggerCronJob={jobId => { - void triggerCronJob(jobId) - .then(() => refreshCronJobs()) - .catch(() => undefined) - }} - /> - ) - - // The persistent xterm layer (one host per terminal tab), CSS-overlaid onto the - // pane's <TerminalSlot />. Lives in main's stacking context (not the root overlay - // layer) so pane resize handles still paint above it. Terminals own their state - // (incl. a snapshotted cwd) independent of the session, so switching sessions - // never rebuilds or closes them; toggling the pane never rebuilds the shells. - const mainOverlays = <PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} /> - - const overlays = ( - <> - <RemoteDisplayBanner /> - {!isSecondaryWindow() && <DesktopInstallOverlay />} - {!isSecondaryWindow() && ( - <DesktopOnboardingOverlay - enabled={gatewayState === 'open'} - onCompleted={() => { - void refreshHermesConfig() - void refreshCurrentModel() - void queryClient.invalidateQueries({ queryKey: ['model-options'] }) - }} - requestGateway={requestGateway} - /> - )} - <ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} /> - <SessionPickerOverlay onResume={resumeSession} /> - <ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} /> - <UpdatesOverlay /> - <GatewayConnectingOverlay /> - <BootFailureOverlay /> - <CommandPalette /> - <PetGenerateOverlay /> - <SessionSwitcher /> - <FileActionDialogs /> - <RemoteFolderPicker /> - - {settingsOpen && ( - <Suspense fallback={null}> - <SettingsView - gateway={gatewayRef.current} - onClose={closeOverlayToPreviousRoute} - onConfigSaved={() => { - void refreshHermesConfig() - void refreshCurrentModel() - void queryClient.invalidateQueries({ queryKey: ['model-options'] }) - }} - onMainModelChanged={(provider, model) => { - setCurrentProvider(provider) - setCurrentModel(model) - updateModelOptionsCache(provider, model, true) - void refreshCurrentModel() - void queryClient.invalidateQueries({ queryKey: ['model-options'] }) - }} - /> - </Suspense> - )} - - {commandCenterOpen && ( - <Suspense fallback={null}> - <CommandCenterView - initialSection={commandCenterInitialSection} - onClose={closeOverlayToPreviousRoute} - onDeleteSession={removeSession} - onNavigateRoute={path => navigate(path)} - onOpenSession={sessionId => navigate(sessionRoute(sessionId))} - /> - </Suspense> - )} - - {agentsOpen && ( - <Suspense fallback={null}> - <AgentsView onClose={closeOverlayToPreviousRoute} /> - </Suspense> - )} - - {cronOpen && ( - <Suspense fallback={null}> - <CronView - onClose={closeOverlayToPreviousRoute} - onOpenSession={sessionId => navigate(sessionRoute(sessionId))} - /> - </Suspense> - )} - - {profilesOpen && ( - <Suspense fallback={null}> - <ProfilesView onClose={closeOverlayToPreviousRoute} /> - </Suspense> - )} - - {starmapOpen && ( - <Suspense fallback={null}> - <StarmapView onClose={closeOverlayToPreviousRoute} /> - </Suspense> - )} - </> - ) - - const chatView = ( - <ChatView - gateway={gatewayRef.current} - maxVoiceRecordingSeconds={voiceMaxRecordingSeconds} - modelMenuContent={modelMenuContent} - onAddContextRef={composer.addContextRefAttachment} - onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} - onAttachDroppedItems={composer.attachDroppedItems} - onAttachImageBlob={composer.attachImageBlob} - onBranchInNewChat={branchInNewChat} - onCancel={cancelRun} - onDeleteSelectedSession={() => { - if (selectedStoredSessionId) { - void removeSession(selectedStoredSessionId) - } - }} - onDismissError={dismissError} - onEdit={editMessage} - onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)} - onPickFiles={() => void composer.pickContextPaths('file')} - onPickFolders={() => void composer.pickContextPaths('folder')} - onPickImages={() => void composer.pickImages()} - onReload={reloadFromMessage} - onRemoveAttachment={id => void composer.removeAttachment(id)} - onRestoreToMessage={restoreToMessage} - onRetryResume={sessionId => void resumeSession(sessionId, true)} - onSteer={steerPrompt} - onSubmit={submitText} - onThreadMessagesChange={handleThreadMessagesChange} - onToggleSelectedPin={toggleSelectedPin} - onTranscribeAudio={transcribeVoiceAudio} - /> - ) - - // Flipped layout mirrors the default: sessions sidebar → right, file - // browser + preview rail → left. Same panes, swapped sides. - const sidebarSide = panesFlipped ? 'right' : 'left' - const railSide = panesFlipped ? 'left' : 'right' - - // Other sidebars docked as real columns on the terminal's rail. Force-collapsed - // hover-reveal overlays (narrow window) don't take a column, so they don't count. - const railColumnOpen = - (chatOpen && Boolean(previewTarget || filePreviewTarget) && previewPaneOpen) || - (chatOpen && !narrowViewport && fileBrowserOpen) || - (chatOpen && Boolean(currentCwd.trim()) && !narrowViewport && reviewOpen) - - // Once the terminal would share its rail with another sidebar, drop it to a - // full-width row beneath them rather than cramming in one more skinny column. - const terminalAsRow = terminalSidebarOpen && railColumnOpen - - const previewPane = ( - <Pane - disabled={!chatOpen || (!previewTarget && !filePreviewTarget)} - id={PREVIEW_PANE_ID} - key="preview" - maxWidth={PREVIEW_RAIL_MAX_WIDTH} - minWidth={PREVIEW_RAIL_MIN_WIDTH} - resizable - side={railSide} - width={PREVIEW_RAIL_PANE_WIDTH} - > - {chatOpen ? ( - <ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} /> - ) : null} - </Pane> - ) - - const fileBrowserPane = ( - <Pane - defaultOpen={false} - disabled={!chatOpen} - forceCollapsed={narrowViewport} - hoverReveal - id="file-browser" - key="file-browser" - maxWidth={FILE_BROWSER_MAX_WIDTH} - minWidth={FILE_BROWSER_MIN_WIDTH} - resizable - side={railSide} - width={FILE_BROWSER_DEFAULT_WIDTH} - > - {/* Key on the project (cwd) so switching projects unmounts the old tree and - mounts a fresh one straight into its skeleton — no stale-then-blip. */} - <RightSidebarPane - key={currentCwd || 'no-cwd'} - onActivateFile={path => composer.insertContextPathInlineRef(path)} - onActivateFolder={path => composer.insertContextPathInlineRef(path, true)} - /> - </Pane> - ) - - const reviewPane = ( - <Pane - defaultOpen - // The diff pane only makes sense in a workspace, so force it shut when the - // session is detached — "No diffs" then only ever shows inside a project, - // never as a second empty panel next to the file browser. - // Docked (wide): `reviewOpen` gates it. Narrow: drop `reviewOpen` from the - // gate so the pane stays mounted as a collapsed overlay — `toggleReview` - // then slides it in/out via the forced-reveal pin, exactly like ⌘B for the - // sidebar. Still requires a repo (no diffs to show otherwise). - disabled={!chatOpen || !currentCwd.trim() || (!narrowViewport && !reviewOpen)} - forceCollapsed={narrowViewport} - hoverReveal - id={REVIEW_PANE_ID} - key="review" - maxWidth={FILE_BROWSER_MAX_WIDTH} - minWidth={FILE_BROWSER_MIN_WIDTH} - // Mobile overlay sits at its min width — compact, doesn't bury the chat. - overlayWidth={FILE_BROWSER_MIN_WIDTH} - resizable - side={railSide} - width={FILE_BROWSER_DEFAULT_WIDTH} - > - <ReviewPane key={currentCwd || 'no-cwd'} /> - </Pane> - ) - - const terminalPane = ( - <Pane - bottomRow={terminalAsRow} - defaultOpen - disabled={!terminalSidebarOpen} - divider - height="38vh" - id="terminal-sidebar" - key="terminal-sidebar" - maxHeight="80vh" - maxWidth="80vw" - minHeight="8rem" - minWidth="22vw" - resizable - side={railSide} - width="42vw" - > - {/* As a column the terminal clears the titlebar; as a bottom row it sits - below the rail's panes (so it fills its row edge-to-edge) and gets a - left border separating it from the chat — the column-mode separator - lives on the resize sash, which moves to the top edge as a row. */} - <div - className={cn( - 'relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-editor-surface-background)', - terminalAsRow ? 'border-l border-(--ui-stroke-secondary) pt-0' : 'pt-(--titlebar-height)' - )} - > - <TerminalPaneChrome /> - </div> - </Pane> - ) - - return ( - <AppShell - leftStatusbarItems={leftStatusbarItems} - leftTitlebarTools={titlebarToolGroups.flat.left} - mainOverlays={mainOverlays} - onOpenSettings={openSettings} - overlays={overlays} - previewPaneOpen={chatOpen && Boolean(previewTarget || filePreviewTarget)} - statusbarItems={statusbarItems} - terminalPaneOpen={terminalSidebarOpen} - titlebarTools={titlebarToolGroups.flat.right} - > - {!isSecondaryWindow() && ( - <Pane - forceCollapsed={narrowViewport} - hoverReveal - id="chat-sidebar" - maxWidth={SIDEBAR_MAX_WIDTH} - minWidth={SIDEBAR_DEFAULT_WIDTH} - onOverlayActiveChange={setSidebarOverlayMounted} - resizable - side={sidebarSide} - width={`${SIDEBAR_DEFAULT_WIDTH}px`} - > - {sidebar} - </Pane> - )} - <PaneMain> - <Routes> - <Route element={chatView} index /> - <Route element={chatView} path=":sessionId" /> - <Route - element={ - <Suspense fallback={null}> - <SkillsView setStatusbarItemGroup={setStatusbarItemGroup} /> - </Suspense> - } - path="skills" - /> - <Route - element={ - <Suspense fallback={null}> - <MessagingView setStatusbarItemGroup={setStatusbarItemGroup} /> - </Suspense> - } - path="messaging" - /> - <Route - element={ - <Suspense fallback={null}> - <ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} /> - </Suspense> - } - path="artifacts" - /> - <Route element={null} path="cron" /> - <Route element={null} path="profiles" /> - <Route element={null} path="settings" /> - <Route element={null} path="command-center" /> - <Route element={null} path="agents" /> - <Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" /> - <Route element={<LegacySessionRedirect />} path="sessions/:sessionId" /> - <Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" /> - </Routes> - </PaneMain> - {/* - Order within a side maps to column order. Default (rail on the right): - main | terminal | preview | file-browser. Flipped (rail on the left): - mirror to file-browser | preview | terminal | main so terminal stays - adjacent to the chat. - */} - {panesFlipped ? fileBrowserPane : terminalPane} - {previewPane} - {reviewPane} - {panesFlipped ? terminalPane : fileBrowserPane} - </AppShell> - ) -} - -function LegacySessionRedirect() { - const { sessionId } = useParams() - - return <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} /> -} diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index eb893c3675a0..2672e95a676e 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -97,6 +97,7 @@ function fakeDesktop() { })), onBootProgress: vi.fn(() => () => undefined), onBackendExit: vi.fn(() => () => undefined), + onConnectionApplied: vi.fn(() => () => undefined), onPowerResume: vi.fn(() => () => undefined), onWindowStateChanged: vi.fn(() => () => undefined), touchBackend: vi.fn(async () => undefined), diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 26a4e2ce7c8e..a9c07ad57fd1 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -23,6 +23,7 @@ import { setPrimaryGateway, touchSecondaryGateways } from '@/store/gateway' +import { $gatewaySwitching, wipeSessionListsForGatewaySwitch } from '@/store/gateway-switch' import { notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile' import { @@ -38,6 +39,7 @@ import { setCurrentCwd, setSessionsLoading } from '@/store/session' +import { resetTileRuntimeBindings } from '@/store/session-states' import type { RpcEvent } from '@/types/hermes' // After this many consecutive failed reconnects (≈45s with the 1→15s backoff) @@ -130,7 +132,7 @@ export function useGatewayBoot({ } const attemptReconnect = async () => { - if (cancelled || reconnecting || gatewayOpen()) { + if (cancelled || reconnecting || gatewayOpen() || $gatewaySwitching.get()) { return } @@ -166,6 +168,9 @@ export function useGatewayBoot({ } reconnectAttempt = 0 + // A respawned backend re-mints (recycles) runtime ids, so any tile's + // bound runtime id is now stale — drop them so each tile re-resumes. + resetTileRuntimeBindings() // Resync state that may have moved on the backend while we were asleep. await callbacksRef.current.refreshHermesConfig().catch(() => undefined) await callbacksRef.current.refreshSessions().catch(() => undefined) @@ -181,7 +186,7 @@ export function useGatewayBoot({ } finally { reconnecting = false - if (!cancelled && !gatewayOpen()) { + if (!cancelled && !gatewayOpen() && !$gatewaySwitching.get()) { if (reconnectAttempt >= RECONNECT_ESCALATE_AFTER && !escalated) { escalated = true failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost')) @@ -193,7 +198,7 @@ export function useGatewayBoot({ } function scheduleReconnect() { - if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen()) { + if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen() || $gatewaySwitching.get()) { return } @@ -207,7 +212,7 @@ export function useGatewayBoot({ } const reconnectNow = () => { - if (cancelled || !bootCompleted) { + if (cancelled || !bootCompleted || $gatewaySwitching.get()) { return } @@ -221,7 +226,98 @@ export function useGatewayBoot({ } } - const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload)) + // Adopt the profile the primary (window) backend booted as, so same-profile + // resumes are no-op swaps and reconnects target the right backend. + // Best-effort: a missing preference means "default". Shared by boot + soft + // switch. + async function adoptPrimaryProfile() { + try { + const pref = await desktop.profile?.get?.() + const profileKey = (pref?.profile ?? '').trim() || 'default' + $activeGatewayProfile.set(profileKey) + setPrimaryGateway(gateway, profileKey) + void ensureGatewayForProfile(profileKey) + } catch { + $activeGatewayProfile.set('default') + } + } + + // Seed the working dir from the backend default on a fresh view (nothing + // open yet). Shared by boot + soft switch. + async function seedDefaultCwd() { + await ensureDefaultWorkspaceCwd() + const remoteDefault = await desktopDefaultCwd().catch(() => null) + + if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) { + setCurrentCwd(remoteDefault.cwd) + setCurrentBranch(remoteDefault.branch || '') + } + } + + // Soft gateway-mode apply: main tore down the primary without reloading. + // Wipe session lists so skeletons retrigger, then re-dial in place. + const softSwitch = async () => { + if (cancelled) { + return + } + + $gatewaySwitching.set(true) + clearReconnectTimer() + reconnectAttempt = 0 + escalated = false + reauthNotified = false + wipeSessionListsForGatewaySwitch() + + try { + gateway.close() + closeSecondaryGateways() + + const conn = await desktop.getConnection() + + if (cancelled) { + return + } + + publish(conn) + const wsUrl = await resolveGatewayWsUrl(desktop, conn) + await gateway.connect(wsUrl) + + if (cancelled) { + return + } + + await adoptPrimaryProfile() + await seedDefaultCwd() + await callbacksRef.current.refreshHermesConfig().catch(() => undefined) + await callbacksRef.current.refreshSessions().catch(() => undefined) + completeDesktopBoot() + bootCompleted = true + } catch (err) { + if (!cancelled) { + const message = err instanceof Error ? err.message : String(err) + failDesktopBoot(message) + notifyError(err, translateNow('boot.errors.desktopBootFailed')) + setSessionsLoading(false) + } + } finally { + $gatewaySwitching.set(false) + } + } + + const offBootProgress = desktop.onBootProgress(payload => { + // Soft switch / post-boot startHermes re-emits progress — ignore so the + // cold-boot CONNECTING overlay stays down. Errors still surface. + if ($gatewaySwitching.get() || bootCompleted) { + if (payload.error) { + applyDesktopBootProgress(payload) + } + + return + } + + applyDesktopBootProgress(payload) + }) + void desktop .getBootProgress() .then(snapshot => applyDesktopBootProgress(snapshot)) @@ -258,18 +354,20 @@ export function useGatewayBoot({ if (bootCompleted) { completeDesktopBoot() } - } else if (bootCompleted && (st === 'closed' || st === 'error')) { + } else if (bootCompleted && !$gatewaySwitching.get() && (st === 'closed' || st === 'error')) { // The socket dropped after a healthy boot (typically sleep/wake). Try // to bring it back instead of leaving the composer stuck disabled. scheduleReconnect() } }) - const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event)) + const sourceProfile = normalizeProfileKey($activeGatewayProfile.get()) + const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile })) // Wake signals: power resume (macOS/Windows), network coming back, and the // window regaining focus/visibility. Each nudges an immediate reconnect. const offPowerResume = desktop.onPowerResume?.(() => reconnectNow()) + const offConnectionApplied = desktop.onConnectionApplied?.(() => void softSwitch()) const onOnline = () => reconnectNow() @@ -319,6 +417,10 @@ export function useGatewayBoot({ }) const offExit = desktop.onBackendExit(() => { + if ($gatewaySwitching.get()) { + return + } + if ($desktopBoot.get().running || $desktopBoot.get().visible) { failDesktopBoot(translateNow('boot.errors.backgroundExitedDuringStartup')) } @@ -357,31 +459,14 @@ export function useGatewayBoot({ return } - // Record which profile the primary (window) backend booted as, so - // same-profile resumes are no-op swaps and any reconnect targets the - // right backend. Best-effort: a missing preference means "default". - try { - const pref = await desktop.profile?.get?.() - const profileKey = (pref?.profile ?? '').trim() || 'default' - $activeGatewayProfile.set(profileKey) - setPrimaryGateway(gateway, profileKey) - void ensureGatewayForProfile(profileKey) - } catch { - $activeGatewayProfile.set('default') - } + await adoptPrimaryProfile() setDesktopBootStep({ phase: 'renderer.config', message: translateNow('boot.steps.loadingSettings'), progress: 97 }) - await ensureDefaultWorkspaceCwd() - const remoteDefault = await desktopDefaultCwd().catch(() => null) - - if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) { - setCurrentCwd(remoteDefault.cwd) - setCurrentBranch(remoteDefault.branch || '') - } + await seedDefaultCwd() await callbacksRef.current.refreshHermesConfig() @@ -411,6 +496,7 @@ export function useGatewayBoot({ return () => { cancelled = true + $gatewaySwitching.set(false) clearReconnectTimer() clearInterval(keepaliveTimer) offWorking() @@ -419,6 +505,7 @@ export function useGatewayBoot({ window.removeEventListener('online', onOnline) document.removeEventListener('visibilitychange', onVisible) offPowerResume?.() + offConnectionApplied?.() offState() offEvent() offExit() diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 379c719ad036..fb0cf2ce3702 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -1,18 +1,16 @@ import { useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' +import { closeActiveTab } from '@/app/chat/close-tab' import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store' import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals' -import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell' -import { matchesQuery } from '@/hooks/use-media-query' -import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions' +import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store' +import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions' import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo' import { $repoStatus } from '@/store/coding-status' import { toggleCommandPalette } from '@/store/command-palette' import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds' import { - CHAT_SIDEBAR_PANE_ID, - FILE_BROWSER_PANE_ID, requestSessionSearchFocus, setFileBrowserOpen, toggleFileBrowserOpen, @@ -30,6 +28,7 @@ import { import { requestNewWorktree } from '@/store/projects' import { toggleReview } from '@/store/review' import { setModelPickerOpen } from '@/store/session' +import { reopenLastClosedTile } from '@/store/session-states' import { $switcherOpen, closeSwitcher, @@ -45,7 +44,6 @@ import { openNewSessionInNewWindow } from '@/store/windows' import { useTheme } from '@/themes/context' import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus' -import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants' import { AGENTS_ROUTE, ARTIFACTS_ROUTE, @@ -62,6 +60,8 @@ export interface KeybindRuntimeDeps { toggleCommandCenter: () => void /** Drop to a fresh new-session draft. */ startFreshSession: () => void + /** Open a fresh session as a tab in the main zone (⌘T), leaving the primary. */ + openNewSessionTab: () => void /** Pin/unpin the active session. */ toggleSelectedPin: () => void } @@ -82,7 +82,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { const profileSwitchHandlers: HandlerMap = {} for (let slot = 1; slot <= PROFILE_SLOT_COUNT; slot += 1) { - profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot) + // ⌘1…⌘9 switch the FOCUSED zone's tab when it's a real tab strip; only a + // single-pane (or unfocused) layout falls through to the profile switch. + profileSwitchHandlers[`profile.switch.${slot}`] = () => { + if (!activateTreeTabSlot(slot)) { + switchProfileToSlot(slot) + } + } } const goToSession = (sessionId: null | string) => { @@ -138,9 +144,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { deps.startFreshSession() window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut')) }, + 'session.newTab': () => deps.openNewSessionTab(), 'session.newWindow': () => void openNewSessionInNewWindow(), - 'session.next': () => stepSession(1), - 'session.prev': () => stepSession(-1), + // ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus + // falls through to the recent-session switcher. + 'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)), + 'session.prev': () => void (cycleTreeTabInFocusedZone(-1) || stepSession(-1)), ...sessionSlotHandlers, 'session.focusSearch': requestSessionSearchFocus, 'session.togglePin': deps.toggleSelectedPin, @@ -148,20 +157,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { // through instead of silently doing nothing). 'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(), - 'view.toggleSidebar': () => { - if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) { - window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } })) - } else { - toggleSidebarOpen() - } - }, - 'view.toggleRightSidebar': () => { - if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) { - window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } })) - } else { - toggleFileBrowserOpen() - } - }, + // Narrow-viewport reveal is handled inside the store toggles now. + 'view.toggleSidebar': toggleSidebarOpen, + // ⌘J toggles the right sidebar — but a layout with no right side (e.g. + // terminal-on-bottom) would leave it a dead key, so it falls back to the + // terminal there. The single "secondary panel" toggle. + 'view.toggleRightSidebar': () => + layoutHasRootSide('right') ? toggleFileBrowserOpen() : setTerminalTakeover(!$terminalTakeover.get()), 'view.toggleReview': toggleReview, 'view.showFiles': showFiles, 'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()), @@ -177,6 +179,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { 'view.prevTerminal': () => $terminalTakeover.get() && cycleTerminal(-1), 'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(), 'view.flipPanes': togglePanesFlipped, + // ⌘W: close the focused tab (terminal / preview target / zone tree tab). + // On macOS the menu accelerator owns ⌘W and routes through the same + // closeActiveTab via IPC (see use-desktop-integrations); this binding is + // the Win/Linux path where ⌘W reaches the renderer directly. + 'view.closeTab': () => void closeActiveTab(), + 'view.reopenTab': reopenLastClosedTile, 'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'), @@ -242,7 +250,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { return } - const handler = handlersRef.current[actionId] + // Built-in handlers first (they carry React context); contributed + // actions bring their own `run` through the registry. + const handler = handlersRef.current[actionId] ?? contributedKeybindHandler(actionId) if (!handler) { return diff --git a/apps/desktop/src/app/index.tsx b/apps/desktop/src/app/index.tsx index ad8f79afebf6..a1dc8140b5ad 100644 --- a/apps/desktop/src/app/index.tsx +++ b/apps/desktop/src/app/index.tsx @@ -1 +1,6 @@ -export { DesktopController as default } from './desktop-controller' +// The app root is the contribution-driven shell: panes, titlebar/statusbar +// items, keybinds, palette commands, routes, and themes all register through +// the contribution registry (src/contrib) — core surfaces use the same calls +// plugins do. Everything lives under ./contrib: the wiring (gateway boot, +// sessions, streams) + pane surfaces, and the pane/layout registration. +export { ContribController as default } from './contrib' diff --git a/apps/desktop/src/app/messaging/index.test.tsx b/apps/desktop/src/app/messaging/index.test.tsx index a7d9273c0c90..b078a2043b1d 100644 --- a/apps/desktop/src/app/messaging/index.test.tsx +++ b/apps/desktop/src/app/messaging/index.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -53,12 +53,16 @@ afterEach(() => { async function renderMessaging() { const { MessagingView } = await import('./index') + let result: ReturnType<typeof render> + await act(async () => { + result = render( + <MemoryRouter> + <MessagingView /> + </MemoryRouter> + ) + }) - return render( - <MemoryRouter> - <MessagingView /> - </MemoryRouter> - ) + return result! } describe('MessagingView setup-guide link', () => { @@ -82,7 +86,9 @@ describe('MessagingView setup-guide link', () => { await renderMessaging() const link = await screen.findByText('Open setup guide') - fireEvent.click(link) + await act(async () => { + fireEvent.click(link) + }) await waitFor(() => expect(openExternalLink).toHaveBeenCalledWith(docsUrl)) }) diff --git a/apps/desktop/src/app/overlays/overlay-view.tsx b/apps/desktop/src/app/overlays/overlay-view.tsx index 61efdabb5a9b..4f05170d2d1b 100644 --- a/apps/desktop/src/app/overlays/overlay-view.tsx +++ b/apps/desktop/src/app/overlays/overlay-view.tsx @@ -1,8 +1,10 @@ -import { type ReactNode, useEffect } from 'react' +import { type CSSProperties, type ReactNode, useEffect } from 'react' +import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { translateNow } from '@/i18n' +import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' @@ -32,8 +34,10 @@ export function OverlayView({ // stop propagation themselves, so opening (e.g.) the model picker inside // Settings still closes the picker first instead of the underlying overlay. useEffect(() => { + const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.overlay) + const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || event.defaultPrevented) { + if (event.key !== 'Escape' || event.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.overlay)) { return } @@ -44,7 +48,10 @@ export function OverlayView({ window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + releaseLayer() + } }, [onClose]) return ( @@ -64,6 +71,12 @@ export function OverlayView({ } }} role="presentation" + // Window-level chrome: overlays always clear the real titlebar. The + // contrib shell zeroes --titlebar-height for CONTENT areas (panes sit + // below its in-flow title bar), and CSS vars inherit through the DOM — + // so a fixed overlay mounted inside a zone would read 0 and bleed to + // the edges. Re-pin the real height at the overlay root. + style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties} > <div className={cn( diff --git a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx index ae3c1860b7c7..d635186e34ed 100644 --- a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx +++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx @@ -1,6 +1,7 @@ import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef, useState } from 'react' +import { PetHeartField, playVibeHearts } from '@/components/chat/vibe-hearts' import { PetBubble } from '@/components/pet/pet-bubble' import { PetSprite } from '@/components/pet/pet-sprite' import { type PetZoomAnchor, usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture' @@ -72,6 +73,8 @@ export function PetOverlayApp() { const zoomAnchorRef = useRef<PetZoomAnchor | null>(null) const petRef = useRef<HTMLDivElement | null>(null) const inputRef = useRef<HTMLInputElement | null>(null) + // Last mirrored reaction id — a bump means the main window fired a reaction. + const lastReactionRef = useRef<number | null>(null) const ignoreRef = useRef(true) const composerOpenRef = useRef(false) const clickTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined) @@ -91,6 +94,19 @@ export function PetOverlayApp() { setBusy(Boolean(payload.busy)) setAwaitingResponse(Boolean(payload.awaiting)) setUnread(Boolean(payload.unread)) + + // Play a reaction on a new id (ignore the first sync, which just primes it). + const reaction = payload.reaction ?? null + + if (lastReactionRef.current === null) { + lastReactionRef.current = reaction?.id ?? 0 + } else if (reaction && reaction.id > lastReactionRef.current) { + lastReactionRef.current = reaction.id + + if (reaction.kind === 'vibe') { + playVibeHearts() + } + } }) // Tell the main renderer we're mounted so it pushes the current frame (the @@ -416,6 +432,12 @@ export function PetOverlayApp() { <div style={{ lineHeight: 0, position: 'relative' }}> <PetSprite info={info} /> + {/* Hearts on the popped-out pet — identical to in-window. */} + <PetHeartField + petH={(info.frameH ?? DEFAULT_FRAME_H) * (info.scale ?? DEFAULT_SCALE)} + petW={(info.frameW ?? DEFAULT_FRAME_W) * (info.scale ?? DEFAULT_SCALE)} + /> + {/* Mail icon: only when a finish landed while you were away. Jumps to the app's most recent thread. Anchored to the sprite (kept inside its box so the overlay's click-through hit-test still catches it); diff --git a/apps/desktop/src/app/right-sidebar/review/file-tree.tsx b/apps/desktop/src/app/right-sidebar/review/file-tree.tsx index e09c964b72c5..5eb66c0fc696 100644 --- a/apps/desktop/src/app/right-sidebar/review/file-tree.tsx +++ b/apps/desktop/src/app/right-sidebar/review/file-tree.tsx @@ -218,6 +218,7 @@ function ReviewDirRow({ <span className="min-w-0 flex-1 truncate" title={node.name}> {node.name} </span> + {!open && <DiffCount added={node.added} className="text-[0.64rem] leading-4" removed={node.removed} />} </div> {open && node.children && ( <ReviewNodeList animate={animate} depth={depth + 1} motion={useMotion} nodes={node.children} /> diff --git a/apps/desktop/src/app/right-sidebar/review/index.tsx b/apps/desktop/src/app/right-sidebar/review/index.tsx index 3e06f38cc873..2e50a1f874f5 100644 --- a/apps/desktop/src/app/right-sidebar/review/index.tsx +++ b/apps/desktop/src/app/right-sidebar/review/index.tsx @@ -84,7 +84,9 @@ export function ReviewPane() { {(loading || isRepo) && ( <RightSidebarSectionHeader data-suppress-pane-reveal-side=""> <div className="flex min-w-0 flex-1"> - <SidebarPanelLabel>{c.review}</SidebarPanelLabel> + {/* Pure self-naming label — redundant under a zone tab that already + says "review", so the zone header hides it (styles.css). */} + <SidebarPanelLabel data-pane-self-label="">{c.review}</SidebarPanelLabel> </div> <Tip label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}> <Button diff --git a/apps/desktop/src/app/right-sidebar/terminal/instance.tsx b/apps/desktop/src/app/right-sidebar/terminal/instance.tsx index 399407d81696..933916b640ef 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/instance.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/instance.tsx @@ -19,12 +19,20 @@ interface TerminalInstanceProps { cwd: string active: boolean onAddSelectionToChat: (text: string, label?: string) => void + restoreCwd?: string reviveBuffer?: string } /** One persistent xterm+PTY. Every open tab stays mounted (so its shell and * scrollback survive tab switches); only the active one is shown. */ -export function TerminalInstance({ id, active, cwd, onAddSelectionToChat, reviveBuffer }: TerminalInstanceProps) { +export function TerminalInstance({ + id, + active, + cwd, + onAddSelectionToChat, + restoreCwd, + reviveBuffer +}: TerminalInstanceProps) { const { t } = useI18n() const { addSelectionToChat, hostRef, selection, selectionStyle, status } = useTerminalSession({ @@ -32,6 +40,7 @@ export function TerminalInstance({ id, active, cwd, onAddSelectionToChat, revive cwd, active, onAddSelectionToChat, + restoreCwd, reviveBuffer, onShell: shell => reportTerminalShell(id, shell) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx new file mode 100644 index 000000000000..60b2113916af --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx @@ -0,0 +1,36 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { $bindings } from '@/store/keybinds' + +import { TerminalRail } from './rail' +import { $activeTerminalId, $terminals } from './terminals' + +describe('TerminalRail', () => { + beforeEach(() => { + $terminals.set([{ auto: true, cwd: 'C:\\repo', id: 'term-1', kind: 'user', title: 'PowerShell' }]) + $activeTerminalId.set('term-1') + $bindings.set({ ...$bindings.get(), 'view.showTerminal': ['ctrl+`'] }) + }) + + afterEach(() => { + cleanup() + $terminals.set([]) + $activeTerminalId.set(null) + }) + + it('keeps a hotkey label inline inside the portaled tooltip decoration', async () => { + const view = render(<TerminalRail />) + + fireEvent.pointerMove(screen.getByRole('tab', { name: '1. PowerShell' }), { pointerType: 'mouse' }) + await screen.findByRole('tooltip') + + const content = document.querySelector<HTMLElement>('[data-slot="tooltip-content"]') + const label = content?.firstElementChild?.firstElementChild + + expect(content).not.toBeNull() + expect(view.container.contains(content)).toBe(false) + expect(label?.classList.contains('inline-flex')).toBe(true) + expect(label?.classList.contains('flex')).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx index c5a07bb8e6df..8a81a16925b8 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx @@ -8,7 +8,7 @@ import { ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu' -import { Tip } from '@/components/ui/tooltip' +import { Tip, TipHintLabel } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' @@ -30,18 +30,6 @@ import { const RAIL_ACTION = 'grid size-6 place-items-center rounded text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring [-webkit-app-region:no-drag]' -/** Tooltip label with a trailing hotkey hint (the user's live binding). */ -function hintLabel(text: string, combo?: string) { - return combo ? ( - <span className="flex items-center gap-2"> - <span>{text}</span> - <span className="opacity-55">{formatCombo(combo)}</span> - </span> - ) : ( - text - ) -} - /** Thin icon "bookmark" strip blended into the terminal surface, shown whenever a * terminal exists. Each square is a tab (name + hotkey on hover); close via the * shell's `exit`, middle-click, or the context menu. */ @@ -78,7 +66,10 @@ export function TerminalRail() { /> ))} <li className="flex w-full justify-center"> - <Tip label={hintLabel(t.rightSidebar.terminalNew, newHint)} side="left"> + <Tip + label={<TipHintLabel hint={newHint && formatCombo(newHint)} text={t.rightSidebar.terminalNew} />} + side="left" + > <button aria-label={t.rightSidebar.terminalNew} className={cn(RAIL_ACTION, 'size-7 text-(--ui-text-quaternary)')} @@ -129,7 +120,7 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T className="absolute inset-y-0.5 right-0 w-0.5 rounded-l-sm bg-(--ui-stroke-primary)" /> )} - <Tip label={hintLabel(label, toggleHint)} side="left"> + <Tip label={<TipHintLabel hint={toggleHint && formatCombo(toggleHint)} text={label} />} side="left"> <button aria-label={label} aria-selected={active} diff --git a/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts b/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts new file mode 100644 index 000000000000..9e02179368e2 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' + +import { cleanReviveSnapshot, isIdlePromptOnly, parseOscCwd } from './use-terminal-session' + +// A default-PowerShell idle prompt: no blank-line separator before it. +const PS_PROMPT = 'PS C:\\Users\\Aleksandr>' + +describe('isIdlePromptOnly', () => { + it('is true for an empty or whitespace-only buffer', () => { + expect(isIdlePromptOnly('')).toBe(true) + expect(isIdlePromptOnly('\r\n \r\n')).toBe(true) + }) + + it('is true for a lone prompt line', () => { + expect(isIdlePromptOnly(PS_PROMPT)).toBe(true) + }) + + it('is true for a buffer that is only repeated identical prompts (accumulation)', () => { + expect(isIdlePromptOnly([PS_PROMPT, PS_PROMPT, PS_PROMPT].join('\r\n'))).toBe(true) + }) + + it('ignores blank gaps between repeated prompts (the "gapped" variant)', () => { + expect(isIdlePromptOnly([PS_PROMPT, '', '', PS_PROMPT].join('\r\n'))).toBe(true) + }) + + it('is false when the buffer holds a real command and output', () => { + expect(isIdlePromptOnly([PS_PROMPT, 'cd project', 'PS C:\\Users\\Aleksandr\\project>'].join('\r\n'))).toBe(false) + }) + + it('is false when two different prompts are present (cwd actually changed)', () => { + expect(isIdlePromptOnly([PS_PROMPT, 'PS C:\\Users\\Aleksandr\\project>'].join('\r\n'))).toBe(false) + }) +}) + +describe('cleanReviveSnapshot', () => { + it('drops a spaced trailing prompt block after a blank separator (starship)', () => { + const snapshot = ['echo hi', 'hi', '', PS_PROMPT].join('\r\n') + + expect(cleanReviveSnapshot(snapshot)).toBe('echo hi\r\nhi') + }) + + it('drops a multi-line prompt block after a blank separator (powerline)', () => { + const snapshot = ['work', '', '┌─ user@host ~/project', '└─$'].join('\r\n') + + expect(cleanReviveSnapshot(snapshot)).toBe('work') + }) + + it('drops a single-line trailing prompt with no preceding blank line (PowerShell)', () => { + // Default PowerShell prints no blank line before its prompt; the fresh shell + // reprints it on boot, so the redundant idle prompt must be trimmed here. + const snapshot = ['echo hi', 'hi', PS_PROMPT].join('\r\n') + + expect(cleanReviveSnapshot(snapshot)).toBe('echo hi\r\nhi') + }) + + it('keeps command output and drops only the trailing prompt on a long history', () => { + const history = ['cmd1', 'out1', 'cmd2', 'out2'] + const snapshot = [...history, PS_PROMPT].join('\r\n') + + expect(cleanReviveSnapshot(snapshot)).toBe(history.join('\r\n')) + }) + + it('reduces a lone prompt to an empty buffer', () => { + expect(cleanReviveSnapshot(PS_PROMPT)).toBe('') + expect(cleanReviveSnapshot([PS_PROMPT, '', ''].join('\r\n'))).toBe('') + }) + + it('returns empty for a blank-only buffer without throwing', () => { + expect(cleanReviveSnapshot('')).toBe('') + expect(cleanReviveSnapshot('\r\n \r\n')).toBe('') + }) +}) + +describe('parseOscCwd', () => { + it('parses an OSC 7 file URI and percent-decodes it', () => { + expect(parseOscCwd(7, 'file://host/Users/al/my%20project')).toBe('/Users/al/my project') + }) + + it('strips the leading slash from a Windows OSC 7 file URI', () => { + expect(parseOscCwd(7, 'file:///C:/Users/Aleksandr/project')).toBe('C:/Users/Aleksandr/project') + }) + + it('ignores non-file OSC 7 payloads', () => { + expect(parseOscCwd(7, 'https://example.com')).toBeNull() + expect(parseOscCwd(7, '')).toBeNull() + }) + + it('parses an OSC 9;9 cwd payload and unquotes it', () => { + expect(parseOscCwd(9, '9;"C:\\Users\\Aleksandr"')).toBe('C:\\Users\\Aleksandr') + expect(parseOscCwd(9, '9;/home/al/src')).toBe('/home/al/src') + }) + + it('ignores OSC 9 sub-commands other than 9;<path> (e.g. progress)', () => { + expect(parseOscCwd(9, '4;3')).toBeNull() + expect(parseOscCwd(9, 'some notification')).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts b/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts index b04e1e12710c..eb99e7a78211 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts @@ -87,4 +87,37 @@ describe('terminal store persistence', () => { closeAllTerminals() expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull() }) + + it('restores and persists the last observed cwd so a reopened tab lands where the user cd-d', async () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + activeTerminalId: 'term-one', + terminals: [{ auto: false, cwd: '/repo', id: 'term-one', restoreCwd: '/repo/packages/api', title: 'zsh' }] + }) + ) + + const { $terminals, updateTerminalRestoreCwd } = await loadTerminalStore() + + expect($terminals.get()[0]?.restoreCwd).toBe('/repo/packages/api') + + updateTerminalRestoreCwd('term-one', '/repo/packages/web') + expect($terminals.get()[0]?.restoreCwd).toBe('/repo/packages/web') + expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}').terminals[0].restoreCwd).toBe( + '/repo/packages/web' + ) + }) + + it('never attaches a restore cwd to an agent tab and ignores empty values', async () => { + const { $terminals, createTerminal, ensureAgentTerminal, updateTerminalRestoreCwd } = await loadTerminalStore() + + const userId = createTerminal('/repo') + const agentId = ensureAgentTerminal('proc-1', 'background task')! + + updateTerminalRestoreCwd(agentId, '/somewhere') + updateTerminalRestoreCwd(userId, ' ') + + expect($terminals.get().find(term => term.id === agentId)?.restoreCwd).toBeUndefined() + expect($terminals.get().find(term => term.id === userId)?.restoreCwd).toBeUndefined() + }) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts index 2d716242ab3b..c86f57aeeae7 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts @@ -19,6 +19,10 @@ export interface TerminalEntry { * (the project root if opened in one, else the backend's default). Switching * sessions never moves or recreates a terminal. */ cwd: string + /** Last observed working directory of the live shell (tracked via the PTY + * cwd probe / OSC 7). Used to reopen the tab where the user last `cd`'d + * rather than the original launch dir. User tabs only. */ + restoreCwd?: string /** Serialized xterm scrollback from the last session, replayed on relaunch so * the tab reopens with its recent history (VS Code parity). Processes are NOT * revived — a fresh shell starts beneath the restored buffer. Captured live @@ -34,6 +38,7 @@ interface PersistedTerminalEntry { auto: boolean cwd: string id: string + restoreCwd?: string reviveBuffer?: string title: string } @@ -59,6 +64,7 @@ function sanitizePersistedTerminal(value: unknown): PersistedTerminalEntry | nul const id = typeof record.id === 'string' ? record.id.trim() : '' const title = typeof record.title === 'string' ? record.title.trim() : '' const cwd = typeof record.cwd === 'string' ? record.cwd : '' + const restoreCwd = typeof record.restoreCwd === 'string' && record.restoreCwd ? record.restoreCwd : undefined const reviveBuffer = typeof record.reviveBuffer === 'string' ? record.reviveBuffer : undefined if (!id) { @@ -69,6 +75,7 @@ function sanitizePersistedTerminal(value: unknown): PersistedTerminalEntry | nul auto: typeof record.auto === 'boolean' ? record.auto : true, cwd, id, + ...(restoreCwd ? { restoreCwd } : {}), ...(reviveBuffer ? { reviveBuffer } : {}), title: title || 'Terminal' } @@ -116,6 +123,7 @@ function persistTerminals(list: readonly TerminalEntry[], activeTerminalId: null auto: term.auto, cwd: term.cwd, id: term.id, + ...(term.restoreCwd ? { restoreCwd: term.restoreCwd } : {}), ...(term.reviveBuffer ? { reviveBuffer: term.reviveBuffer } : {}), title: term.title })) @@ -309,6 +317,27 @@ export function updateTerminalReviveBuffer(id: string, reviveBuffer: string): vo ) } +/** Record the shell's latest working directory for a tab so the next launch can + * restart the PTY there instead of the original launch dir. User tabs only; + * no-ops when the value is empty or unchanged to avoid redundant persistence. */ +export function updateTerminalRestoreCwd(id: string, restoreCwd: string): void { + const next = restoreCwd.trim() + + if (!next) { + return + } + + $terminals.set( + $terminals.get().map(term => { + if (term.id !== id || term.kind !== 'user' || term.restoreCwd === next) { + return term + } + + return { ...term, restoreCwd: next } + }) + ) +} + export function renameTerminal(id: string, title: string): void { const trimmed = title.trim() diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 931b8fec624d..c1b09487c6fa 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -21,7 +21,7 @@ import { terminalSelectionLabel, terminalTheme } from './selection' -import { closeTerminal, updateTerminalReviveBuffer } from './terminals' +import { closeTerminal, updateTerminalRestoreCwd, updateTerminalReviveBuffer } from './terminals' // How many scrollback lines to serialize for relaunch restore. Mirrors VS Code's // terminal.integrated.persistentSessionScrollback default; the store caps the @@ -33,6 +33,11 @@ const PERSISTENT_SESSION_SCROLLBACK = 200 // renderer tears down), then at most once per window while output streams. const SNAPSHOT_THROTTLE_MS = 750 +// Minimum gap between main-side PTY cwd probes. The probe spawns lsof on macOS, +// so keep it well throttled — cwd only changes on a `cd`, which the reporter +// already reads off the next output snapshot anyway. +const CWD_PROBE_THROTTLE_MS = 2000 + // True once the page/app is tearing down (Cmd+Q, Alt+F4, window close, reload). // App quit kills the PTYs from the main process, which fires onExit in the // renderer — but React skips effect cleanups on teardown, so the per-instance @@ -168,38 +173,55 @@ function stripInitialPromptGap(data: string) { return prefix } +// A row's content with ANSI escapes and all whitespace stripped — '' for a +// spacer / prompt-gap / zsh `%` marker row. +const visibleText = (line: string) => stripEscapeSequences(line).replace(/[\s%]/g, '') + // Trim the shell's trailing idle prompt from a serialized snapshot before it's // persisted. Without it, the saved buffer ends in the old prompt, so the next -// launch replays it directly above the fresh shell's prompt ("double bar"). The -// prompt is the short block after the last blank line (starship's add_newline -// gap); only a short tail is dropped, so real command output is never trimmed and -// configs without that blank line simply keep the historical prompt (no loss). -function cleanReviveSnapshot(serialized: string): string { - const visible = (line: string) => stripEscapeSequences(line).replace(/[\s%]/g, '') +// launch replays it directly above the fresh shell's prompt ("double bar"). +// +// An interactive shell always reprints its prompt after a command finishes, so +// the tail of an idle buffer is the prompt, never real history. Two prompt +// shapes exist: +// - Spaced/multi-line (starship add_newline, powerline): a blank line sits +// just above the prompt, so the short block after the last blank is dropped. +// - Single-line (default PowerShell `PS C:\..>`, bash `user@host:~$`): no blank +// separator, so the final line itself is the prompt and is dropped. +// The fresh shell reprints the current prompt on boot either way, so only the +// redundant idle prompt is removed — command output is preserved. +export function cleanReviveSnapshot(serialized: string): string { const lines = serialized.split(/\r?\n/) - while (lines.length && visible(lines[lines.length - 1]) === '') { + while (lines.length && !visibleText(lines[lines.length - 1])) { lines.pop() } - let lastBlank = -1 - - for (let i = lines.length - 1; i >= 0; i -= 1) { - if (visible(lines[i]) === '') { - lastBlank = i - - break - } + if (lines.length === 0) { + return '' } - // A prompt is a short block; a long tail after the blank is real output, leave it. - if (lastBlank >= 0 && lines.length - 1 - lastBlank <= 3) { - lines.length = lastBlank - } + const lastBlank = lines.findLastIndex(line => !visibleText(line)) + const spacedPrompt = lastBlank >= 0 && lines.length - 1 - lastBlank <= 3 + + // Spaced prompt (starship/powerline): drop the block after the blank + // separator. Otherwise the last line is the single-line prompt itself. + lines.length = spacedPrompt ? lastBlank : lines.length - 1 return lines.join('\r\n') } +// True when a revive buffer holds no real scrollback: empty, or only repeats of +// one line (the idle prompt). This is the idle-accumulation signature (#61572) — +// each relaunch replayed the saved prompt(s) and the fresh shell printed one more +// below, growing the tab by a line per cycle. Real sessions vary (prompt + +// command + output), so genuine short histories are never mistaken for idle. +export function isIdlePromptOnly(serialized: string): boolean { + const lines = serialized.split(/\r?\n/).map(visibleText).filter(Boolean) + + return lines.length === 0 || lines.every(line => line === lines[0]) +} + interface UseTerminalSessionOptions { /** Renderer-side terminal id (the tab handle), used to key the agent reader. */ id: string @@ -207,12 +229,52 @@ interface UseTerminalSessionOptions { /** Only the active tab is visible, owns the agent reader, and runs injections. */ active: boolean onAddSelectionToChat: (text: string, label?: string) => void + /** Last observed shell cwd from the previous session; the fresh PTY starts + * here (falling back to `cwd`) so a prior `cd` survives a relaunch. */ + restoreCwd?: string /** Serialized scrollback from the previous session, replayed once on mount. */ reviveBuffer?: string /** Reports the resolved shell name once the PTY is live (for the tab label). */ onShell?: (shell: string) => void } +// Parse a working directory out of a cwd-reporting OSC payload. Covers OSC 7 +// (`file://host/path`, emitted by many bash/zsh integrations) and OSC 9;9 +// (`9;<path>`, ConEmu/Windows-Terminal style some PowerShell profiles emit). +// Returns null for anything unrecognized so callers can ignore it. +export function parseOscCwd(code: 7 | 9, payload: string): string | null { + if (code === 9) { + // OSC 9;9;<path> — the leading "9;" selects the cwd sub-command. + if (!payload.startsWith('9;')) { + return null + } + + const raw = payload.slice(2).trim().replace(/^"|"$/g, '') + + return raw || null + } + + // OSC 7 — a file URI. Strip the scheme + authority and percent-decode. + const match = /^file:\/\/[^/]*(\/.*)$/.exec(payload.trim()) + + if (!match) { + return null + } + + let raw = match[1] + + try { + raw = decodeURIComponent(raw) + } catch { + // Keep the undecoded path if it isn't valid percent-encoding. + } + + // Windows file URIs carry a leading slash before the drive (`/C:/Users`). + const windows = /^\/[A-Za-z]:[\\/]/.exec(raw) + + return (windows ? raw.slice(1) : raw) || null +} + // Bind the palette to the live skin surface so the terminal blends with the app // (and the contrast clamp has a real background to work against). function withSurface(theme: ReturnType<typeof terminalTheme>) { @@ -314,6 +376,7 @@ export function useTerminalSession({ cwd, active, onAddSelectionToChat, + restoreCwd, reviveBuffer, onShell }: UseTerminalSessionOptions) { @@ -336,6 +399,15 @@ export function useTerminalSession({ // Snapshot the revive buffer once: live snapshots feed updateTerminalReviveBuffer // and would otherwise re-arm replay on every store-driven re-render. const initialReviveBufferRef = useRef(reviveBuffer) + // The cwd to boot the fresh PTY in — the last dir the prior session observed + // (survives a `cd`), captured once so store-driven re-renders don't move it. + const initialRestoreCwdRef = useRef(restoreCwd) + // Latest cwd seen this session; de-dupes redundant store writes. + const lastObservedCwdRef = useRef<string | null>(null) + // Whether the user ever fed input into this session (keystrokes, paste, + // drag-and-drop paths, or an injected command). Gates idle-buffer handling in + // persistSnapshot so an untouched tab never re-saves an accumulating snapshot. + const hasSessionActivityRef = useRef(false) const shellNameRef = useRef('shell') const selectionLabelRef = useRef('') const selectionRef = useRef('') @@ -473,6 +545,50 @@ export function useTerminalSession({ term.write('\r\n') } + // Track the shell's working directory so a reopened tab restarts where the + // user last `cd`'d. Two independent signals feed it: cwd-reporting OSC + // sequences (immediate, for shells configured to emit them) and a periodic + // PTY cwd probe on the main side (shell-agnostic on POSIX). The store + // updater de-dupes, so both feeding it is harmless. + const recordCwd = (next: string | null | undefined) => { + const value = (next ?? '').trim() + + if (!value || value === lastObservedCwdRef.current) { + return + } + + lastObservedCwdRef.current = value + updateTerminalRestoreCwd(id, value) + } + + const cwdOscHandlers = ([7, 9] as const).map(code => + term.parser.registerOscHandler(code, payload => { + recordCwd(parseOscCwd(code, payload)) + + return false // let the sequence propagate; we only observe it + }) + ) + + cleanup.push(() => cwdOscHandlers.forEach(handler => handler.dispose())) + + let cwdProbeAt = 0 + + const probeCwd = () => { + const sessionId = sessionIdRef.current + + if (!sessionId || !terminalApi.cwd || Date.now() - cwdProbeAt < CWD_PROBE_THROTTLE_MS) { + return + } + + cwdProbeAt = Date.now() + void terminalApi + .cwd(sessionId) + .then(recordCwd) + .catch(() => { + // Best-effort: no cwd probe on this platform (e.g. Windows). + }) + } + // Capture the buffer on a leading-edge throttle and persist synchronously via // the store. No unload hook: by the time the user quits, a recent snapshot is // already on disk (the prior beforeunload-based attempt lost the last output). @@ -486,12 +602,31 @@ export function useTerminalSession({ lastSnapshotAt = Date.now() + // No user input this session: never re-serialize. The live buffer now holds + // the replayed history plus a fresh boot prompt, and re-saving that is + // exactly what grew idle tabs by one prompt line per relaunch (#61572). + // If the buffer we loaded carried no real scrollback (empty, or only a + // repeated prompt), clear it so the next launch shows a single fresh prompt + // and any pre-existing accumulation heals. Otherwise leave the prior + // snapshot untouched so real history from an earlier active session + // survives an idle reopen instead of being overwritten. + if (!hasSessionActivityRef.current) { + if (isIdlePromptOnly(initialReviveBufferRef.current ?? '')) { + updateTerminalReviveBuffer(id, '') + } + + return + } + try { const snapshot = serialize.serialize({ scrollback: PERSISTENT_SESSION_SCROLLBACK }) updateTerminalReviveBuffer(id, cleanReviveSnapshot(snapshot)) } catch { // Best-effort restore: never let serialization break a live terminal. } + + // A user command may have `cd`'d; refresh the persisted cwd (throttled). + probeCwd() } const scheduleSnapshot = () => { @@ -544,6 +679,7 @@ export function useTerminalSession({ return } + hasSessionActivityRef.current = true void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `) term.focus() triggerHaptic('selection') @@ -640,6 +776,7 @@ export function useTerminalSession({ }) const dataDisposable = term.onData(data => { + hasSessionActivityRef.current = true const id = sessionIdRef.current if (id) { @@ -661,7 +798,10 @@ export function useTerminalSession({ const startSession = () => void terminalApi - .start({ cols: term.cols, cwd, rows: term.rows }) + // Prefer the prior session's last cwd so a reopened tab lands where the + // user last `cd`'d; the main side falls back to the launch cwd (then + // home) if that dir no longer exists. + .start({ cols: term.cols, cwd: initialRestoreCwdRef.current || cwd, rows: term.rows }) .then(session => { if (disposed) { void terminalApi.dispose(session.id) @@ -846,6 +986,7 @@ export function useTerminalSession({ return } + hasSessionActivityRef.current = true void window.hermesDesktop?.terminal?.write(sessionId, `${command}\r`) $terminalInjection.set(null) termRef.current?.focus() diff --git a/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx b/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx index b8b62f509996..a15899c816b1 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx @@ -56,6 +56,7 @@ export function TerminalWorkspace({ onAddSelectionToChat }: TerminalWorkspacePro id={term.id} key={term.id} onAddSelectionToChat={onAddSelectionToChat} + restoreCwd={term.restoreCwd} reviveBuffer={term.reviveBuffer} /> ) diff --git a/apps/desktop/src/app/routes.ts b/apps/desktop/src/app/routes.ts index 66ab264e474b..8db7ee7c3900 100644 --- a/apps/desktop/src/app/routes.ts +++ b/apps/desktop/src/app/routes.ts @@ -1,3 +1,8 @@ +import { atom } from 'nanostores' +import type { ReactNode } from 'react' + +import { registry } from '@/contrib/registry' + export const SESSION_ROUTE_PREFIX = '/' export const NEW_CHAT_ROUTE = '/' export const SETTINGS_ROUTE = '/settings' @@ -16,6 +21,11 @@ export type AppView = | 'chat' | 'command-center' | 'cron' + // A contributed (plugin) full page at its own route — NOT chat. Without this + // distinction contributed paths fell through appViewForPath's 'chat' default, + // so the sidebar kept a session highlighted and the titlebar kept the + // session-title dropdown while a plugin page was showing. + | 'extension' | 'messaging' | 'profiles' | 'settings' @@ -56,6 +66,52 @@ export const APP_ROUTES = [ const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view])) const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path)) +// ── Contributed routes — the `routes` registry area ───────────────────────── +// A contribution mounts a FULL PAGE in the workspace pane at `data.path` +// (`render` on the contribution itself, like every other area). Contributed +// paths are reserved exactly like APP_ROUTES so the session-id parser never +// mistakes them for a session route. Navigate with `host.navigate(path)`. + +export const ROUTES_AREA = 'routes' + +/** Payload of a `routes` contribution's `data`. */ +export interface RouteContribution { + /** Absolute path, e.g. `/kanban`. One segment; no params. */ + path: string +} + +export function contributedRoutes(): Array<{ key: string; path: string; title?: string; render: () => ReactNode }> { + return registry + .getArea(ROUTES_AREA) + .map(c => ({ + key: `${c.source ?? 'core'}:${c.id}`, + path: (c.data as RouteContribution | undefined)?.path ?? '', + title: c.title, + render: c.render! + })) + .filter(route => Boolean(route.path.startsWith('/') && route.render) && !RESERVED_PATHS.has(route.path)) +} + +function isContributedPath(pathname: string): boolean { + return contributedRoutes().some(route => route.path === pathname) +} + +// ── Contributed sidebar nav — the `sidebar.nav` registry area ──────────────── +// A DATA contribution adds a row to the sidebar's top nav (below Artifacts). +// Pair with a ROUTES_AREA page: the row navigates to `path` and lights up +// while the app is there. + +export const SIDEBAR_NAV_AREA = 'sidebar.nav' + +/** Payload of a `sidebar.nav` data contribution. */ +export interface SidebarNavContribution { + /** Codicon name, e.g. `'project'`. */ + codicon: string + label: string + /** Route to navigate to (usually a contributed page's path). */ + path: string +} + // Views that render as a full-screen modal card (OverlayView) over the shell. // While one is open the app's titlebar control clusters must hide so they don't // bleed over the overlay (they sit at a higher z-index than the overlay card). @@ -77,7 +133,7 @@ export function isNewChatRoute(pathname: string): boolean { } export function routeSessionId(pathname: string): string | null { - if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname)) { + if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname) || isContributedPath(pathname)) { return null } @@ -95,5 +151,25 @@ export function appViewForPath(pathname: string): AppView { return 'chat' } + if (isContributedPath(pathname)) { + return 'extension' + } + return APP_VIEW_BY_PATH.get(pathname) ?? 'chat' } + +/** True while the workspace pane shows a FULL PAGE (skills/messaging/ + * artifacts/plugin routes) instead of the chat. Published by the wiring + * (which owns the router location); the workspace pane contribution mirrors + * it as `headerVeto` so the zone tab bar stands down on pages. Overlays + * (settings/…) don't count — the chat stays beneath them. */ +export const $workspaceIsPage = atom(false) + +export function syncWorkspaceIsPage(pathname: string): void { + const view = appViewForPath(pathname) + const isPage = view !== 'chat' && !isOverlayView(view) + + if (isPage !== $workspaceIsPage.get()) { + $workspaceIsPage.set(isPage) + } +} diff --git a/apps/desktop/src/app/session-switcher.tsx b/apps/desktop/src/app/session-switcher.tsx index 21bd6b801855..53926209cef9 100644 --- a/apps/desktop/src/app/session-switcher.tsx +++ b/apps/desktop/src/app/session-switcher.tsx @@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom' import { sessionTitle } from '@/lib/chat-runtime' import { cn } from '@/lib/utils' -import { $attentionSessionIds, $workingSessionIds } from '@/store/session' +import { $attentionSessionIds, $unreadFinishedSessionIds, $workingSessionIds } from '@/store/session' import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher' import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud' @@ -19,6 +19,7 @@ export function SessionSwitcher() { const index = useStore($switcherIndex) const working = useStore($workingSessionIds) const attention = useStore($attentionSessionIds) + const unread = useStore($unreadFinishedSessionIds) const navigate = useNavigate() const activeRef = useRef<HTMLDivElement>(null) @@ -33,6 +34,7 @@ export function SessionSwitcher() { const workingIds = new Set(working) const attentionIds = new Set(attention) + const unreadIds = new Set(unread) const pick = (sessionId: string) => { closeSwitcher() @@ -74,7 +76,7 @@ export function SessionSwitcher() { }} ref={selected ? activeRef : undefined} > - <SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} /> + <SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} unread={unreadIds.has(session.id)} /> <span className="min-w-0 flex-1 truncate">{sessionTitle(session)}</span> {i < 9 && ( <span @@ -95,12 +97,18 @@ export function SessionSwitcher() { ) } -function SwitcherDot({ attention, working }: { attention: boolean; working: boolean }) { +function SwitcherDot({ attention, working, unread }: { attention: boolean; working: boolean; unread: boolean }) { return ( <span className={cn( 'size-1 shrink-0 rounded-full', - attention ? 'bg-amber-400' : working ? 'animate-pulse bg-(--ui-accent)' : 'bg-(--ui-text-quaternary)/50' + attention + ? 'bg-amber-400' + : working + ? 'animate-pulse bg-(--ui-accent)' + : unread + ? 'bg-emerald-500' + : 'bg-(--ui-text-quaternary)/50' )} /> ) diff --git a/apps/desktop/src/app/session/hooks/use-cwd-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-cwd-actions.test.tsx new file mode 100644 index 000000000000..aba64c51738d --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-cwd-actions.test.tsx @@ -0,0 +1,102 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react' +import type { MutableRefObject } from 'react' +import { useEffect } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + $currentBranch, + $currentCwd, + $newChatWorkspaceTarget, + setCurrentBranch, + setCurrentCwd, + setCurrentCwdTransient, + setNewChatWorkspaceTarget +} from '@/store/session' + +import { useCwdActions } from './use-cwd-actions' + +type CwdActionsHandle = ReturnType<typeof useCwdActions> + +function deferred<T>() { + let resolve!: (value: T) => void + + const promise = new Promise<T>(done => { + resolve = done + }) + + return { promise, resolve } +} + +function Harness({ + activeSessionIdRef, + onReady, + requestGateway +}: { + activeSessionIdRef: MutableRefObject<string | null> + onReady: (handle: CwdActionsHandle) => void + requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> +}) { + const actions = useCwdActions({ + activeSessionId: activeSessionIdRef.current, + activeSessionIdRef, + requestGateway + }) + + useEffect(() => { + onReady(actions) + }, [actions, onReady]) + + return null +} + +describe('useCwdActions draft workspace target', () => { + beforeEach(() => { + setCurrentCwd('') + setCurrentBranch('') + setNewChatWorkspaceTarget(undefined) + }) + + afterEach(() => { + cleanup() + setCurrentCwd('') + setCurrentBranch('') + setNewChatWorkspaceTarget(undefined) + vi.restoreAllMocks() + }) + + it('ignores stale draft cwd normalization after a newer no-workspace target wins', async () => { + const projectInfo = deferred<{ branch?: string; cwd?: string }>() + const requestGateway = vi.fn(async () => projectInfo.promise as never) + const activeSessionIdRef: MutableRefObject<string | null> = { current: null } + let handle: CwdActionsHandle | null = null + + render( + <Harness + activeSessionIdRef={activeSessionIdRef} + onReady={h => (handle = h)} + requestGateway={requestGateway} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + let pendingChange!: Promise<void> + + await act(async () => { + pendingChange = handle!.changeSessionCwd('/stale-workspace') + }) + + expect($newChatWorkspaceTarget.get()).toBe('/stale-workspace') + + setNewChatWorkspaceTarget(null) + setCurrentCwdTransient('') + projectInfo.resolve({ branch: 'main', cwd: '/normalized-stale-workspace' }) + + await act(async () => { + await pendingChange + }) + + expect($newChatWorkspaceTarget.get()).toBeNull() + expect($currentCwd.get()).toBe('') + expect($currentBranch.get()).toBe('') + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts index 2308191b8b1b..8226a2f59501 100644 --- a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts @@ -2,7 +2,13 @@ import { type MutableRefObject, useCallback } from 'react' import { useI18n } from '@/i18n' import { notify, notifyError } from '@/store/notifications' -import { $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session' +import { + $currentCwd, + $newChatWorkspaceTargetGeneration, + setCurrentBranch, + setCurrentCwd, + setNewChatWorkspaceTarget +} from '@/store/session' import type { SessionRuntimeInfo } from '@/types/hermes' interface CwdActionsOptions { @@ -55,6 +61,7 @@ export function useCwdActions({ if (!activeSessionId) { setCurrentCwd(trimmed) + const workspaceGeneration = setNewChatWorkspaceTarget(trimmed) try { const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { @@ -62,15 +69,22 @@ export function useCwdActions({ cwd: trimmed }) + if ($newChatWorkspaceTargetGeneration.get() !== workspaceGeneration || activeSessionIdRef.current) { + return + } + // Adopt the backend's normalized cwd so the persisted workspace and // branch stay consistent with what the agent will use. if (info.cwd) { setCurrentCwd(info.cwd) + setNewChatWorkspaceTarget(info.cwd) } setCurrentBranch(info.branch || '') } catch { - setCurrentBranch('') + if ($newChatWorkspaceTargetGeneration.get() === workspaceGeneration && !activeSessionIdRef.current) { + setCurrentBranch('') + } } return @@ -103,7 +117,7 @@ export function useCwdActions({ }) } }, - [activeSessionId, copy, onSessionRuntimeInfo, requestGateway] + [activeSessionId, activeSessionIdRef, copy, onSessionRuntimeInfo, requestGateway] ) return { changeSessionCwd, refreshProjectBranch } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/approval-mode-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/approval-mode-event.test.tsx new file mode 100644 index 000000000000..4616bca54487 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/approval-mode-event.test.tsx @@ -0,0 +1,104 @@ +import { QueryClient } from '@tanstack/react-query' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { useEffect, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ClientSessionState } from '@/app/types' +import { createClientSessionState } from '@/lib/chat-runtime' +import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode' +import { $activeGatewayProfile } from '@/store/profile' +import type { RpcEvent } from '@/types/hermes' + +import { useMessageStream } from './index' + +const ACTIVE_SID = 'session-active' +let handleEvent: ((event: RpcEvent) => void) | null = null + +function Harness() { + const activeSessionIdRef = useRef<string | null>(ACTIVE_SID) + const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>()) + const queryClientRef = useRef(new QueryClient()) + + const stream = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession: vi.fn(async () => undefined), + queryClient: queryClientRef.current, + refreshHermesConfig: vi.fn(async () => undefined), + refreshSessions: vi.fn(async () => undefined), + sessionStateByRuntimeIdRef, + updateSessionState: (sessionId, updater) => { + const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState() + const next = updater(current) + sessionStateByRuntimeIdRef.current.set(sessionId, next) + + return next + } + }) + + useEffect(() => { + handleEvent = stream.handleGatewayEvent + }, [stream.handleGatewayEvent]) + + return null +} + +async function mountStream() { + render(<Harness />) + await waitFor(() => expect(handleEvent).not.toBeNull()) +} + +describe('live session.info approval mode reconciliation', () => { + beforeEach(() => { + handleEvent = null + $approvalModes.set({}) + $activeGatewayProfile.set('work') + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('reconciles an active-session event under its source gateway profile', async () => { + await mountStream() + + act(() => + handleEvent!({ + payload: { approval_mode: 'off' }, + profile: 'work', + session_id: ACTIVE_SID, + type: 'session.info' + }) + ) + + expect(approvalModeForProfile('work')).toBe('off') + expect(approvalModeForProfile('default')).toBe('smart') + }) + + it('ignores stale session.info from a non-active session on the active gateway', async () => { + await mountStream() + + act(() => + handleEvent!({ + payload: { approval_mode: 'off' }, + profile: 'work', + session_id: 'session-stale', + type: 'session.info' + }) + ) + + expect(approvalModeForProfile('work')).toBe('smart') + }) + + it('does not cache an event under a different active profile when its source profile is absent', async () => { + await mountStream() + $activeGatewayProfile.set('personal') + + act(() => + handleEvent!({ payload: { approval_mode: 'off' }, session_id: ACTIVE_SID, type: 'session.info' }) + ) + + expect(approvalModeForProfile('personal')).toBe('smart') + expect(approvalModeForProfile('work')).toBe('smart') + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx new file mode 100644 index 000000000000..a403bde23800 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/compaction-event.test.tsx @@ -0,0 +1,82 @@ +import { QueryClient } from '@tanstack/react-query' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { useEffect, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ClientSessionState } from '@/app/types' +import { createClientSessionState } from '@/lib/chat-runtime' +import { $compactingSessions, setSessionCompacting } from '@/store/compaction' +import type { RpcEvent } from '@/types/hermes' + +import { useMessageStream } from './index' + +const SID = 'session-1' +const OTHER_SID = 'session-2' +let handleEvent: ((event: RpcEvent) => void) | null = null + +function Harness() { + const activeSessionIdRef = useRef<string | null>(SID) + const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>()) + const queryClientRef = useRef(new QueryClient()) + + const stream = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession: vi.fn(async () => undefined), + queryClient: queryClientRef.current, + refreshHermesConfig: vi.fn(async () => undefined), + refreshSessions: vi.fn(async () => undefined), + sessionStateByRuntimeIdRef, + updateSessionState: (sessionId, updater) => { + const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState() + const next = updater(current) + sessionStateByRuntimeIdRef.current.set(sessionId, next) + + return next + } + }) + + useEffect(() => { + handleEvent = stream.handleGatewayEvent + }, [stream.handleGatewayEvent]) + + return null +} + +async function mountStream() { + render(<Harness />) + await waitFor(() => expect(handleEvent).not.toBeNull()) +} + +function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) { + act(() => handleEvent!({ payload, session_id: SID, type })) +} + +describe('useMessageStream compaction lifecycle', () => { + beforeEach(() => { + handleEvent = null + $compactingSessions.set({}) + }) + + afterEach(() => { + cleanup() + $compactingSessions.set({}) + vi.restoreAllMocks() + }) + + it.each([ + ['message.delta', { text: 'resumed' }], + ['thinking.delta', { text: 'still working' }], + ['reasoning.delta', { text: 'thinking again' }], + ['tool.start', { name: 'terminal', tool_id: 'tool-1' }] + ] as const)('clears the stale compaction phase when %s resumes the turn', async (type, payload) => { + await mountStream() + setSessionCompacting(OTHER_SID, true) + + emit('status.update', { kind: 'compacting' }) + expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true, [SID]: true }) + + emit(type, payload) + + expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true }) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index f057bca4f248..58cabd78e9a4 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -1,16 +1,18 @@ import type { QueryClient } from '@tanstack/react-query' -import { type MutableRefObject, useCallback } from 'react' +import { type MutableRefObject, useCallback, useRef } from 'react' import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream' import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer' import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals' +import { burstVibeHearts } from '@/components/chat/vibe-hearts' import { translateNow } from '@/i18n' import { type GatewayEventPayload, textPart } from '@/lib/chat-messages' import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime' import { playCompletionSound } from '@/lib/completion-sound' -import { gatewayEventRequiresSessionId } from '@/lib/gateway-events' +import { resolveGatewayEventSessionId } from '@/lib/gateway-events' import { triggerHaptic } from '@/lib/haptics' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' +import { reconcileApprovalModeForProfile } from '@/store/approval-mode' import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify' import { setSessionCompacting } from '@/store/compaction' import { refreshBackgroundProcesses } from '@/store/composer-status' @@ -19,10 +21,12 @@ import { dispatchNativeNotification } from '@/store/native-notifications' import { notify } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet' +import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import { followActiveSessionCwd } from '@/store/projects' import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts' import { $currentCwd, + sessionMatchesStoredId, setCurrentBranch, setCurrentCwd, setCurrentFastMode, @@ -47,6 +51,19 @@ import type { ClientSessionState } from '../../../types' import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils' +const COMPACTION_RESUME_EVENT_TYPES = new Set([ + 'message.delta', + 'thinking.delta', + 'reasoning.delta', + 'reasoning.available', + 'moa.reference', + 'moa.aggregating', + 'tool.start', + 'tool.progress', + 'tool.generating', + 'tool.complete' +]) + interface GatewayEventDeps { activeSessionIdRef: MutableRefObject<string | null> compactedTurnRef: MutableRefObject<Set<string>> @@ -92,18 +109,41 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { upsertToolCall } = deps + const unscopedStreamSessionIdRef = useRef<string | null>(null) + return useCallback( (event: RpcEvent) => { const payload = event.payload as GatewayEventPayload | undefined const explicitSid = event.session_id || '' - if (!explicitSid && gatewayEventRequiresSessionId(event.type)) { + const route = resolveGatewayEventSessionId({ + activeSessionId: activeSessionIdRef.current, + eventType: event.type, + explicitSessionId: explicitSid, + unscopedStreamSessionId: unscopedStreamSessionIdRef.current + }) + + unscopedStreamSessionIdRef.current = route.nextUnscopedStreamSessionId + + if (route.drop) { return } - const sessionId = explicitSid || activeSessionIdRef.current + const sessionId = route.sessionId const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current + // Mid-turn compaction does not emit another message.start. The first + // model output or tool event proves summarization has finished and the + // turn has resumed, so retire the phase label without waiting for the + // whole turn to complete. + if ( + sessionId && + COMPACTION_RESUME_EVENT_TYPES.has(event.type) && + compactedTurnRef.current.has(sessionId) + ) { + setSessionCompacting(sessionId, false) + } + if (event.type === 'gateway.ready') { return } else if (event.type === 'session.info') { @@ -116,6 +156,20 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { const providerChanged = typeof payload?.provider === 'string' const runningChanged = typeof payload?.running === 'boolean' + // Config is profile-scoped, but session.info also arrives for background + // sessions. Only an active-session event from the currently active + // gateway may reconcile the foreground cache. Requiring the renderer's + // source tag prevents an event queued before a profile swap from being + // attributed to the newly active profile. + if ( + isActiveEvent && + typeof payload?.approval_mode === 'string' && + event.profile && + normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get()) + ) { + reconcileApprovalModeForProfile(event.profile, payload.approval_mode) + } + if (apply) { if (modelChanged) { setCurrentModel(payload!.model || '') @@ -264,6 +318,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // KawaiiSpinner), not real reasoning. The bottom-of-thread loading // indicator already covers that UX, so we ignore these events to // avoid a duplicative "Thinking" disclosure showing spinner text. + } else if (event.type === 'reaction') { + // Core-detected affection (ily / <3 / good bot) on the user's message. + // Play hearts only for the visible session so background turns stay quiet. + if (isActiveEvent && (payload?.kind ?? 'vibe') === 'vibe') { + burstVibeHearts() + } } else if (event.type === 'reasoning.delta') { if (sessionId) { appendReasoningDelta(sessionId, coerceThinkingText(payload?.text)) @@ -346,7 +406,17 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { } if (payload?.usage) { - setCurrentUsage(current => ({ ...current, ...payload.usage })) + // Per-session twin FIRST (the statusbar reads it for focused tiles); + // the primary-only global mirrors the ACTIVE session — ungated it + // let a background tile's turn overwrite the primary's count. + updateSessionState(sessionId, state => ({ + ...state, + usage: { calls: 0, input: 0, output: 0, total: 0, ...state.usage, ...payload.usage } + })) + + if (isActiveEvent) { + setCurrentUsage(current => ({ ...current, ...payload.usage })) + } } } else if (event.type === 'session.title') { // Live auto-title push (titler runs async, after the turn's refresh). @@ -354,9 +424,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { const nextTitle = typeof payload?.title === 'string' ? payload.title.trim() : '' if (storedId && nextTitle) { - setSessions(prev => - prev.map(s => (s.id === storedId || s._lineage_root_id === storedId ? { ...s, title: nextTitle } : s)) - ) + setSessions(prev => prev.map(s => (sessionMatchesStoredId(s, storedId) ? { ...s, title: nextTitle } : s))) } } else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') { if (!sessionId) { @@ -466,9 +534,11 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { setApprovalRequest({ // false only when a tirith warning forbids it; backend omits the field otherwise. allowPermanent: payload?.allow_permanent !== false, + choices: Array.isArray(payload?.choices) ? payload.choices.filter(choice => typeof choice === 'string') : undefined, command, description, - sessionId: sessionId ?? null + sessionId: sessionId ?? null, + smartDenied: payload?.smart_denied === true }) if (sessionId) { diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx index 92888b4780df..9b7e08cf0ddc 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx @@ -109,7 +109,7 @@ describe('useModelControls', () => { expect($currentProvider.get()).toBe('deepseek') }) - it('routes active-session picker changes through config.set with an explicit provider', async () => { + it('routes active-session picker changes through config.set with an explicit session-scoped provider', async () => { const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never) let controls!: Controls @@ -127,11 +127,33 @@ describe('useModelControls', () => { expect(requestGateway).toHaveBeenCalledWith('config.set', { session_id: 'session-1', key: 'model', - value: 'claude-sonnet-4.6 --provider anthropic' + value: 'claude-sonnet-4.6 --provider anthropic --session' }) expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) }) + it('session-scopes MoA preset selections so they cannot persist as the global gateway default', async () => { + const requestGateway = vi.fn(async () => ({ key: 'model', value: 'BeastMode' }) as never) + let controls!: Controls + + render( + <Harness activeSessionId="session-1" onReady={value => (controls = value)} requestGateway={requestGateway} /> + ) + + await expect( + controls.selectModel({ + model: 'BeastMode', + provider: 'moa' + }) + ).resolves.toBe(true) + + expect(requestGateway).toHaveBeenCalledWith('config.set', { + session_id: 'session-1', + key: 'model', + value: 'BeastMode --provider moa --session' + }) + }) + it('stores a no-session pick as UI state with no gateway or global write', async () => { const requestGateway = vi.fn() let controls!: Controls diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index dba30dd8d140..b302a5fa4554 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -96,7 +96,7 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway await requestGateway('config.set', { session_id: activeSessionId, key: 'model', - value: `${selection.model} --provider ${selection.provider}` + value: `${selection.model} --provider ${selection.provider} --session` }) void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] }) diff --git a/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx index 119bb51a0402..3af7455a9535 100644 --- a/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx @@ -62,9 +62,9 @@ describe('usePreviewRouting', () => { $currentCwd.set('/work') $messages.set([]) $previewTarget.set(null) - window.localStorage.clear() clearSessionPreviewRegistry() handleEvent = () => undefined + window.localStorage.clear() Object.defineProperty(window, 'hermesDesktop', { configurable: true, @@ -78,9 +78,9 @@ describe('usePreviewRouting', () => { cleanup() $messages.set([]) $previewTarget.set(null) - window.localStorage.clear() - clearSessionPreviewRegistry() vi.restoreAllMocks() + clearSessionPreviewRegistry() + window.localStorage.clear() }) it('opens the active session preview from the registry', async () => { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 5ce2ad8aa569..e35589010bd1 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1,11 +1,11 @@ -import { cleanup, render, waitFor } from '@testing-library/react' +import { act, cleanup, render, waitFor } from '@testing-library/react' import type { MutableRefObject } from 'react' import { useEffect, useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { textPart } from '@/lib/chat-messages' import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer' -import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session' +import { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session' import type { SessionInfo } from '@/types/hermes' import { uploadComposerAttachment, usePromptActions } from '.' @@ -43,6 +43,18 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo { } } +// Wrap render() in act() so the Harness's useEffect (onReady callback + +// internal state from usePromptActions) flushes synchronously instead of +// spilling async state updates outside act(). +async function actRender(ui: React.ReactElement) { + let result: ReturnType<typeof render> + await act(async () => { + result = render(ui) + }) + + return result! +} + interface HarnessHandle { cancelRun: () => Promise<void> restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void> @@ -51,7 +63,9 @@ interface HarnessHandle { } function Harness({ + activeSessionIdRef: activeSessionIdRefProp, busyRef, + getRouteToken, onReady, onSeedState, openMemoryGraph, @@ -59,11 +73,14 @@ function Harness({ requestGateway, resumeStoredSession, seedMessages, + selectedStoredSessionIdRef: selectedStoredSessionIdRefProp, storedSessionId, activeSessionId, createBackendSessionForSend }: { + activeSessionIdRef?: MutableRefObject<string | null> busyRef?: MutableRefObject<boolean> + getRouteToken?: () => string onReady: (handle: HarnessHandle) => void onSeedState?: (state: Record<string, unknown>) => void openMemoryGraph?: () => void @@ -71,15 +88,16 @@ function Harness({ requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> resumeStoredSession?: (storedSessionId: string) => Promise<void> | void seedMessages?: unknown[] + selectedStoredSessionIdRef?: MutableRefObject<string | null> storedSessionId?: null | string activeSessionId?: null | string createBackendSessionForSend?: () => Promise<null | string> }) { - const activeSessionIdRef: MutableRefObject<string | null> = { + const activeSessionIdRef: MutableRefObject<string | null> = activeSessionIdRefProp ?? { current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId } - const selectedStoredSessionIdRef: MutableRefObject<string | null> = { + const selectedStoredSessionIdRef: MutableRefObject<string | null> = selectedStoredSessionIdRefProp ?? { current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId } @@ -98,6 +116,7 @@ function Harness({ branchCurrentSession: async () => true, busyRef: localBusyRef, createBackendSessionForSend: createBackendSessionForSend ?? (async () => RUNTIME_SESSION_ID), + getRouteToken: getRouteToken ?? (() => 'token'), handleSkinCommand: () => '', openMemoryGraph: openMemoryGraph ?? (() => undefined), refreshSessions, @@ -118,10 +137,14 @@ function Harness({ useEffect(() => { onReady({ - cancelRun: actions.cancelRun, - restoreToMessage: actions.restoreToMessage, - steerPrompt: actions.steerPrompt, - submitText: actions.submitText + cancelRun: (...args: Parameters<typeof actions.cancelRun>) => + act(async () => actions.cancelRun(...args)) as Promise<void>, + restoreToMessage: (...args: Parameters<typeof actions.restoreToMessage>) => + act(async () => actions.restoreToMessage(...args)) as Promise<void>, + steerPrompt: (...args: Parameters<typeof actions.steerPrompt>) => + act(async () => actions.steerPrompt(...args)) as Promise<boolean>, + submitText: (...args: Parameters<typeof actions.submitText>) => + act(async () => actions.submitText(...args)) as Promise<boolean> }) }, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady]) @@ -146,7 +169,9 @@ describe('usePromptActions /title', () => { ) let handle: HarnessHandle | null = null - render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + await actRender( + <Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} /> + ) await handle!.submitText('/title New title') @@ -170,7 +195,9 @@ describe('usePromptActions /title', () => { ) let handle: HarnessHandle | null = null - render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + await actRender( + <Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} /> + ) await handle!.submitText('/title Fresh chat') @@ -188,7 +215,9 @@ describe('usePromptActions /title', () => { const requestGateway = vi.fn(async () => ({ output: 'Title: Old title' }) as never) let handle: HarnessHandle | null = null - render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + await actRender( + <Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} /> + ) await handle!.submitText('/title') @@ -208,7 +237,9 @@ describe('usePromptActions /title', () => { }) let handle: HarnessHandle | null = null - render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + await actRender( + <Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} /> + ) await handle!.submitText('/title way too long title') @@ -247,7 +278,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={s => states.push(s)} @@ -297,7 +328,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={s => states.push(s)} @@ -335,7 +366,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -365,7 +396,7 @@ describe('usePromptActions desktop slash pickers', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -385,7 +416,7 @@ describe('usePromptActions desktop slash pickers', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} openMemoryGraph={openMemoryGraph} @@ -418,7 +449,7 @@ describe('usePromptActions desktop slash pickers', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -448,7 +479,7 @@ describe('usePromptActions submit / queue drain semantics', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={s => seeds.push(s)} @@ -481,7 +512,7 @@ describe('usePromptActions submit / queue drain semantics', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness busyRef={busyRef} onReady={h => (handle = h)} @@ -523,7 +554,7 @@ describe('usePromptActions submit / queue drain semantics', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -567,7 +598,7 @@ describe('usePromptActions submit / queue drain semantics', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={s => seeds.push(s)} @@ -589,7 +620,7 @@ describe('usePromptActions submit / queue drain semantics', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness busyRef={busyRef} onReady={h => (handle = h)} @@ -615,7 +646,7 @@ describe('usePromptActions steerPrompt', () => { const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -634,7 +665,7 @@ describe('usePromptActions steerPrompt', () => { const requestGateway = vi.fn(async () => ({ status: 'rejected' }) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -647,7 +678,7 @@ describe('usePromptActions steerPrompt', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -658,7 +689,7 @@ describe('usePromptActions steerPrompt', () => { const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -690,7 +721,7 @@ describe('usePromptActions restoreToMessage', () => { let lastState: Record<string, unknown> = {} let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={state => (lastState = state)} @@ -725,7 +756,7 @@ describe('usePromptActions restoreToMessage', () => { let lastState: Record<string, unknown> = {} let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={state => (lastState = state)} @@ -758,7 +789,7 @@ describe('usePromptActions restoreToMessage', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -786,7 +817,7 @@ describe('usePromptActions restoreToMessage', () => { const requestGateway = vi.fn(async () => ({}) as never) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -801,7 +832,7 @@ describe('usePromptActions restoreToMessage', () => { let lastState: Record<string, unknown> = {} let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={state => (lastState = state)} @@ -875,7 +906,7 @@ describe('usePromptActions file attachment sync', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -929,7 +960,7 @@ describe('usePromptActions file attachment sync', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -958,7 +989,7 @@ describe('usePromptActions file attachment sync', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -1018,7 +1049,7 @@ describe('usePromptActions eager-upload races', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) await waitFor(() => expect(handle).not.toBeNull()) @@ -1044,6 +1075,7 @@ describe('usePromptActions sleep/wake session recovery', () => { afterEach(() => { cleanup() + $turnStartedAt.set(null) vi.restoreAllMocks() }) @@ -1076,7 +1108,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -1119,7 +1151,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -1137,6 +1169,32 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID }) }) + it('clears the active and cached turn clocks when stopping a turn', async () => { + const states: Record<string, unknown>[] = [] + const requestGateway = vi.fn(async () => ({}) as never) + $turnStartedAt.set(1_700_000_000_000) + + let handle: HarnessHandle | null = null + await actRender( + <Harness + onReady={h => (handle = h)} + onSeedState={state => states.push(state)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + await handle!.cancelRun() + + expect($turnStartedAt.get()).toBeNull() + expect(states.at(-1)).toMatchObject({ + awaitingResponse: false, + busy: false, + interrupted: true, + turnStartedAt: null + }) + }) + it('surfaces the original error (no resume) when the failure is not "session not found"', async () => { const calls: string[] = [] const states: Record<string, unknown>[] = [] @@ -1152,7 +1210,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} onSeedState={s => states.push(s)} @@ -1182,7 +1240,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -1226,7 +1284,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -1239,7 +1297,7 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(ok).toBe(true) expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message during starved loop' @@ -1266,7 +1324,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness activeSessionId={null} createBackendSessionForSend={createBackendSessionForSend} @@ -1287,7 +1345,18 @@ describe('usePromptActions sleep/wake session recovery', () => { }) it('still creates a new session for a genuine new-chat draft (no stored session selected)', async () => { - const createBackendSessionForSend = vi.fn(async () => RUNTIME_SESSION_ID) + const activeSessionIdRef: MutableRefObject<string | null> = { current: null } + + // Mirror the real createBackendSessionForSend: a successful create + // re-homes the active runtime ref to the session it minted BEFORE + // returning. An inert stub here is what let the new-chat drift-abort + // regression ship green. + const createBackendSessionForSend = vi.fn(async () => { + activeSessionIdRef.current = RUNTIME_SESSION_ID + + return RUNTIME_SESSION_ID + }) + const calls: string[] = [] const requestGateway = vi.fn(async (method: string) => { @@ -1297,9 +1366,10 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( <Harness activeSessionId={null} + activeSessionIdRef={activeSessionIdRef} createBackendSessionForSend={createBackendSessionForSend} onReady={h => (handle = h)} refreshSessions={async () => undefined} @@ -1316,6 +1386,227 @@ describe('usePromptActions sleep/wake session recovery', () => { }) }) +describe('usePromptActions submit session-context isolation (#54527)', () => { + const STORED_SESSION_A = 'stored-project-a' + const STORED_SESSION_B = 'stored-project-b' + const RUNTIME_SESSION_B = 'rt-session-b-wrong' + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('aborts submit when the user switches sessions during session.resume (no misroute)', async () => { + // Exact #54527 failure: user submits in Session A while its runtime binding + // is gone; before resume returns they switch to Session B. Without a pinned + // context the resumed runtime id belongs to B and A's text lands in the + // wrong chat — permanently lost from A. + let releaseResume: () => void = () => {} + const calls: { method: string; params?: Record<string, unknown> }[] = [] + + const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_A } + const activeSessionIdRef: MutableRefObject<string | null> = { current: null } + + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { + calls.push({ method, params }) + + if (method === 'session.resume') { + await new Promise<void>(resolve => { + releaseResume = resolve + }) + + // Simulate the user switching to Session B while resume is in flight. + selectedStoredSessionIdRef.current = STORED_SESSION_B + activeSessionIdRef.current = RUNTIME_SESSION_B + + return { session_id: RUNTIME_SESSION_B } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + <Harness + activeSessionId={null} + activeSessionIdRef={activeSessionIdRef} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={STORED_SESSION_A} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + const submitting = handle!.submitText('carefully composed prompt for project A') + await waitFor(() => expect(calls.some(c => c.method === 'session.resume')).toBe(true)) + releaseResume() + + expect(await submitting).toBe(false) + expect(calls.some(c => c.method === 'prompt.submit')).toBe(false) + expect(calls.find(c => c.method === 'session.resume')?.params).toEqual({ + session_id: STORED_SESSION_A + }) + }) + + it('aborts recovery submit when the user switches sessions during timeout resume', async () => { + const calls: { method: string; params?: Record<string, unknown> }[] = [] + let submitAttempts = 0 + + let releaseResume: () => void = () => {} + + const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_A } + + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { + calls.push({ method, params }) + + if (method === 'prompt.submit') { + submitAttempts += 1 + + if (submitAttempts === 1) { + throw new Error('request timed out: prompt.submit') + } + } + + if (method === 'session.resume') { + await new Promise<void>(resolve => { + releaseResume = resolve + }) + selectedStoredSessionIdRef.current = STORED_SESSION_B + + return { session_id: RUNTIME_SESSION_B } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + <Harness + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={STORED_SESSION_A} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + const submitting = handle!.submitText('message that must not land in session B') + await waitFor(() => expect(calls.some(c => c.method === 'session.resume')).toBe(true)) + releaseResume() + + expect(await submitting).toBe(false) + expect(submitAttempts).toBe(1) + expect(calls.filter(c => c.method === 'prompt.submit')).toHaveLength(1) + expect(calls.find(c => c.method === 'session.resume')?.params).toMatchObject({ + session_id: STORED_SESSION_A + }) + }) + + it('submits the first prompt of a new chat — the create pipeline re-homing selection/route is not user drift', async () => { + // Regression for the #54527 guard breaking every NEW chat: on a fresh draft + // (no stored session, no runtime session) createBackendSessionForSend + // legitimately sets selectedStoredSessionIdRef + navigates to the new + // session's route. Comparing against the pre-create (null) baseline made + // the guard read that self-inflicted move as a user switch and abort, so + // prompt.submit never fired: the message vanished, no DB row was ever + // persisted, and the desktop stranded on a route whose REST reads 404 + // ("Session not found"). + const calls: { method: string; params?: Record<string, unknown> }[] = [] + const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null } + const activeSessionIdRef: MutableRefObject<string | null> = { current: null } + let routeToken = '/' + + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { + calls.push({ method, params }) + + return {} as never + }) + + // Mirror the real createBackendSessionForSend: on success it re-homes the + // refs AND the route to the session it just created. + const createBackendSessionForSend = vi.fn(async () => { + activeSessionIdRef.current = 'rt-new-chat' + selectedStoredSessionIdRef.current = 'stored-new-chat' + routeToken = '/stored-new-chat' + + return 'rt-new-chat' + }) + + let handle: HarnessHandle | null = null + render( + <Harness + activeSessionId={null} + activeSessionIdRef={activeSessionIdRef} + createBackendSessionForSend={createBackendSessionForSend} + getRouteToken={() => routeToken} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={null} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + expect(await handle!.submitText('first message of a brand-new chat')).toBe(true) + expect(createBackendSessionForSend).toHaveBeenCalledTimes(1) + expect(calls.find(c => c.method === 'prompt.submit')?.params).toMatchObject({ + session_id: 'rt-new-chat' + }) + }) + + it('aborts when the user switches sessions during the tail of a successful create', async () => { + // createBackendSessionForSend awaits once more (armed-YOLO apply) AFTER + // committing the refs and returning a real id, so a switch in that window + // escapes its internal null-return drift check. The active ref is the + // tell: every switch path retargets it synchronously, so it no longer + // equals the id create returned. The submit must abort, not adopt the + // switched-to context as its re-pinned baseline. + const calls: { method: string; params?: Record<string, unknown> }[] = [] + const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null } + const activeSessionIdRef: MutableRefObject<string | null> = { current: null } + let routeToken = '/' + + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { + calls.push({ method, params }) + + return {} as never + }) + + const createBackendSessionForSend = vi.fn(async () => { + // The user switched to Session B during the post-commit await: the + // switch path re-homed all three context markers before create returned. + activeSessionIdRef.current = RUNTIME_SESSION_B + selectedStoredSessionIdRef.current = STORED_SESSION_B + routeToken = `/${STORED_SESSION_B}` + + return 'rt-new-chat' + }) + + let handle: HarnessHandle | null = null + render( + <Harness + activeSessionId={null} + activeSessionIdRef={activeSessionIdRef} + createBackendSessionForSend={createBackendSessionForSend} + getRouteToken={() => routeToken} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={null} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + expect(await handle!.submitText('message that must not land in session B')).toBe(false) + expect(calls.some(c => c.method === 'prompt.submit')).toBe(false) + }) +}) + describe('usePromptActions eager attachment upload (drop-time)', () => { afterEach(() => { cleanup() @@ -1353,7 +1644,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => { { id: 'file:devis', kind: 'file', label: 'DEVIS_signed.pdf', path: '/Users/mahmoud/Downloads/DEVIS_signed.pdf' } ]) - render( + await actRender( <Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -1383,7 +1674,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => { $composerAttachments.set([{ id: 'file:x', kind: 'file', label: 'x.pdf', path: '/abs/x.pdf' }]) - render( + await actRender( <Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -1407,7 +1698,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => { } ]) - render( + await actRender( <Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -1422,7 +1713,7 @@ describe('uploadComposerAttachment remote read failures', () => { }) it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => { - // electron/hardening.cjs rejects the readFileDataUrl IPC with this exact + // electron/hardening.ts rejects the readFileDataUrl IPC with this exact // shape when a file exceeds DATA_URL_READ_MAX_BYTES. Object.defineProperty(window, 'hermesDesktop', { configurable: true, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 8ebe8889d16c..2e163fcc80d0 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -5,8 +5,9 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS, transcribeAudio } from '@/hermes' import { useI18n } from '@/i18n' import { stripAnsi } from '@/lib/ansi' -import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' +import { type ChatMessage, textPart } from '@/lib/chat-messages' import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime' +import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' import { normalize } from '@/lib/text' @@ -21,7 +22,15 @@ import { resetSessionBackground } from '@/store/composer-status' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { clearPreviewArtifacts } from '@/store/preview-status' import { clearAllPrompts } from '@/store/prompts' -import { $busy, $connection, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session' +import { + $busy, + $connection, + $messages, + setAwaitingResponse, + setBusy, + setMessages, + setTurnStartedAt +} from '@/store/session' import { clearSessionSubagents } from '@/store/subagents' import { clearSessionTodos } from '@/store/todos' @@ -35,23 +44,28 @@ import type { SessionSteerResponse } from '../../../types' +import { + applyBranchVisibility, + applyReloadOptimistic, + applyRewindOptimistic, + finalizeInterruptedMessages, + planEdit, + planReload, + planRestore, + runRewindSubmit +} from './rewind' import { useSlashCommand } from './slash' import { useSubmitPrompt } from './submit' import { - appendText, blobToDataUrl, delay, friendlyRemoteAttachError, type GatewayRequest, inlineErrorMessage, - isSessionBusyError, isSessionNotFoundError, readFileDataUrlForAttach, readImageForRemoteAttach, - type SubmitTextOptions, - visibleUserIndexAtOrdinal, - visibleUserOrdinal, - withSessionBusyRetry + type SubmitTextOptions } from './utils' interface HandoffResult { @@ -158,6 +172,7 @@ interface PromptActionsOptions { busyRef: MutableRefObject<boolean> branchCurrentSession: () => Promise<boolean> createBackendSessionForSend: (preview?: string | null) => Promise<string | null> + getRouteToken: () => string handleSkinCommand: (arg: string) => string openMemoryGraph: () => void refreshSessions: () => Promise<void> @@ -186,6 +201,7 @@ export function usePromptActions({ busyRef, branchCurrentSession, createBackendSessionForSend, + getRouteToken, handleSkinCommand, openMemoryGraph, refreshSessions, @@ -354,6 +370,7 @@ export function usePromptActions({ busyRef, copy, createBackendSessionForSend, + getRouteToken, requestGateway, selectedStoredSessionIdRef, syncAttachmentsForSubmit, @@ -391,6 +408,13 @@ export function usePromptActions({ return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false } } + const markCompleted = (): HandoffResult => { + appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target)) + notify({ kind: 'success', message: copy.handoff.success(target) }) + + return { ok: true } + } + const deadline = Date.now() + 60_000 let lastState = 'pending' @@ -413,10 +437,7 @@ export function usePromptActions({ } if (state === 'completed') { - appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target)) - notify({ kind: 'success', message: copy.handoff.success(target) }) - - return { ok: true } + return markCompleted() } if (state === 'failed') { @@ -430,10 +451,7 @@ export function usePromptActions({ }).catch(() => null) if (cleanup?.state === 'completed') { - appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target)) - notify({ kind: 'success', message: copy.handoff.success(target) }) - - return { ok: true } + return markCompleted() } return { error: copy.handoff.timedOut, ok: false } @@ -460,7 +478,7 @@ export function usePromptActions({ const submitText = useCallback( async (rawText: string, options?: SubmitTextOptions) => { - const visibleText = rawText.trim() + const visibleText = sanitizeComposerInput(rawText).trim() const attachments = options?.attachments ?? $composerAttachments.get() if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) { @@ -498,22 +516,18 @@ export function usePromptActions({ } setAwaitingResponse(false) - - const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) => - messages - .filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim())) - .map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message)) + setTurnStartedAt(null) if (!sessionId) { releaseBusy() - setMessages(finalizeMessages($messages.get())) + setMessages(finalizeInterruptedMessages($messages.get())) return } updateSessionState(sessionId, state => { const streamId = state.streamId - const messages = finalizeMessages(state.messages, streamId) + const messages = finalizeInterruptedMessages(state.messages, streamId) return { ...state, @@ -523,7 +537,8 @@ export function usePromptActions({ streamId: null, pendingBranchGroup: null, needsInput: false, - interrupted: true + interrupted: true, + turnStartedAt: null } }) @@ -583,7 +598,7 @@ export function usePromptActions({ // window) so the caller can fall back to queueing the words for the next turn. const steerPrompt = useCallback( async (rawText: string): Promise<boolean> => { - const text = rawText.trim() + const text = sanitizeComposerInput(rawText).trim() const sessionId = activeSessionId || activeSessionIdRef.current if (!text || !sessionId) { @@ -617,66 +632,19 @@ export function usePromptActions({ return } - const messages = $messages.get() - const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1 + const plan = planReload($messages.get(), parentId) - const userIndex = - parentIndex >= 0 - ? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user') - : -1 - - if (userIndex < 0) { + if (!plan) { return } - const absoluteUserIndex = parentIndex - userIndex - const userMessage = messages[absoluteUserIndex] - const userText = userMessage ? chatMessageText(userMessage).trim() : '' - - if (!userText) { - return - } - - const targetAssistant = - parentId && messages[parentIndex]?.role === 'assistant' - ? messages[parentIndex] - : messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant') - - const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage) - const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, absoluteUserIndex) - clearNotifications() - updateSessionState(activeSessionId, state => { - const nextUserIndex = state.messages.findIndex( - (message, index) => index > absoluteUserIndex && message.role === 'user' - ) - - const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex - - return { - ...state, - busy: true, - awaitingResponse: true, - pendingBranchGroup: branchGroupId, - sawAssistantPayload: false, - interrupted: false, - messages: [ - ...state.messages.slice(0, absoluteUserIndex + 1), - ...state.messages - .slice(absoluteUserIndex + 1, end) - .map(message => (message.role === 'assistant' ? { ...message, branchGroupId, hidden: true } : message)) - ] - } - }) + updateSessionState(activeSessionId, state => applyReloadOptimistic(state, plan)) try { await requestGateway( 'prompt.submit', - { - session_id: activeSessionId, - text: userText, - truncate_before_user_ordinal: truncateBeforeUserOrdinal - }, + { session_id: activeSessionId, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS ) } catch (err) { @@ -701,41 +669,8 @@ export function usePromptActions({ // fresh turn. Live/stuck turns interrupt first, and a raced "session busy" // response interrupts + retries through the shared busy gate. const submitRewindPrompt = useCallback( - async (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => { - const interrupt = async () => { - try { - await requestGateway('session.interrupt', { session_id: sessionId }) - } catch { - // Best-effort. The submit path still gates on the gateway state. - } - } - - const submit = () => - requestGateway( - 'prompt.submit', - { - session_id: sessionId, - text, - ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) - }, - PROMPT_SUBMIT_REQUEST_TIMEOUT_MS - ) - - if (interruptFirst) { - await interrupt() - } - - try { - await submit() - } catch (err) { - if (!isSessionBusyError(err)) { - throw err - } - - await interrupt() - await withSessionBusyRetry(submit) - } - }, + (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => + runRewindSubmit(requestGateway, sessionId, text, truncateOrdinal, interruptFirst), [requestGateway] ) @@ -748,30 +683,7 @@ export function usePromptActions({ } const messages = $messages.get() - const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user') - - const fallbackIndex = - target?.userOrdinal === null || target?.userOrdinal === undefined - ? -1 - : visibleUserIndexAtOrdinal(messages, target.userOrdinal) - - const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex - const source = messages[sourceIndex] - - if (!source || source.role !== 'user') { - throw new Error('Could not find the message to restore.') - } - - const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim() - - if (!text) { - throw new Error('Cannot restore an empty message.') - } - - const truncateBeforeUserOrdinal = - target?.userOrdinal === null || target?.userOrdinal === undefined - ? visibleUserOrdinal(messages, sourceIndex) - : target.userOrdinal + const plan = planRestore(messages, messageId, target) // The turns we're discarding may have spawned todos and background // processes; they belong to the abandoned timeline, so wipe their status @@ -784,18 +696,10 @@ export function usePromptActions({ setMutableRef(busyRef, true) setBusy(true) setAwaitingResponse(true) - updateSessionState(sessionId, state => ({ - ...state, - busy: true, - awaitingResponse: true, - pendingBranchGroup: null, - sawAssistantPayload: false, - interrupted: false, - messages: state.messages.slice(0, sourceIndex + 1) - })) + updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex)) try { - await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, busyRef.current || $busy.get()) + await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get()) } catch (err) { // The rewind never landed (e.g. the gateway stayed busy past the retry // deadline). Roll the optimistic truncation back to the full original @@ -819,34 +723,17 @@ export function usePromptActions({ const editMessage = useCallback( async (edited: AppendMessage) => { const sessionId = activeSessionId || activeSessionIdRef.current - const sourceId = edited.sourceId || edited.parentId - const text = appendText(edited) - - if (!sessionId || !sourceId || !text || edited.role !== 'user') { - return - } - const messages = $messages.get() - const sourceIndex = messages.findIndex(m => m.id === sourceId) - const source = messages[sourceIndex] + const plan = sessionId ? planEdit(messages, edited) : null - if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) { + if (!sessionId || !plan) { return } // Sending an edit is a revert: rewind to this prompt and re-run with the - // new text. It can fire mid-turn; submitRewindPrompt always interrupts - // first, so a live turn is wound down before the resubmit. - - // Failed turn: optimistic user msg never reached the gateway, so truncating - // by ordinal would 422. Submit as a plain resend instead. - const nextMessage = messages[sourceIndex + 1] - const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error) - const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] } - - // Editing rewinds the conversation to this prompt — same as restore — so - // drop the abandoned timeline's todos/background rows (and kill the live - // processes) before the re-run repopulates them. + // new text (submitRewindPrompt interrupts a live turn first). Same as + // restore, so drop the abandoned timeline's todos/background rows before + // the re-run repopulates them. clearSessionTodos(sessionId) resetSessionBackground(sessionId) clearPreviewArtifacts(sessionId) @@ -855,33 +742,20 @@ export function usePromptActions({ setMutableRef(busyRef, true) setBusy(true) setAwaitingResponse(true) - updateSessionState(sessionId, state => ({ - ...state, - busy: true, - awaitingResponse: true, - pendingBranchGroup: null, - sawAssistantPayload: false, - interrupted: false, - messages: [...state.messages.slice(0, sourceIndex), editedMessage] - })) + updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage)) const isStaleTargetError = (err: unknown) => /no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err)) try { - await submitRewindPrompt( - sessionId, - text, - isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), - busyRef.current || $busy.get() - ) + await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get()) } catch (err) { let surfaced = err - if (!isFailedTurn && isStaleTargetError(err)) { + if (!plan.isFailedTurn && isStaleTargetError(err)) { try { // Already interrupted on the first attempt — submit as a plain resend. - await submitRewindPrompt(sessionId, text, undefined, false) + await submitRewindPrompt(sessionId, plan.text, undefined, false) return } catch (retryErr) { @@ -904,34 +778,13 @@ export function usePromptActions({ const handleThreadMessagesChange = useCallback( (nextMessages: readonly ThreadMessage[]) => { - const visibleIds = new Set(nextMessages.map(m => m.id)) const sessionId = activeSessionIdRef.current if (!sessionId) { return } - updateSessionState(sessionId, state => { - let changed = false - - const messages = state.messages.map(message => { - if (message.role !== 'assistant' || !message.branchGroupId) { - return message - } - - const hidden = !visibleIds.has(message.id) - - if (message.hidden === hidden) { - return message - } - - changed = true - - return { ...message, hidden } - }) - - return changed ? { ...state, messages } : state - }) + updateSessionState(sessionId, state => applyBranchVisibility(state, nextMessages)) }, [activeSessionIdRef, updateSessionState] ) @@ -939,6 +792,9 @@ export function usePromptActions({ return { cancelRun, editMessage, + // Session tiles route their slash input here (targets THEIR session via + // options.sessionId; app-level effects — branch, handoff — act on main). + executeSlashCommand, handleThreadMessagesChange, handoffSession, reloadFromMessage, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts new file mode 100644 index 000000000000..bd934ed448cd --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts @@ -0,0 +1,271 @@ +/** + * Shared rewind/interrupt core for the prompt verbs — the ONE implementation + * of the submit primitive + the pure message math behind cancel / reload / + * restore / edit / branch-visibility. Both the primary chat (`index.ts`) and + * session tiles (`session-tile-actions.ts`) build on these so the two surfaces + * can't silently diverge (the tile's "sends only once" busy-ref bug was exactly + * that class of drift). The functions here are PURE — planners compute from a + * `ChatMessage[]`, optimistic transforms map a `ClientSessionState` to the next + * — so each caller keeps its own state-write + error-handling wiring. + */ + +import type { AppendMessage, ThreadMessage } from '@assistant-ui/react' + +import type { ClientSessionState } from '@/app/types' +import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' +import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' + +import { appendText, isSessionBusyError, visibleUserIndexAtOrdinal, visibleUserOrdinal, withSessionBusyRetry } from './utils' + +type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T> + +/** + * Rewind a turn: `prompt.submit` with an optional `truncate_before_user_ordinal` + * (drops that user turn + everything after). Idle rewinds submit directly + * (interrupting an idle agent can leave a stale interrupt flag that cancels the + * fresh turn); live/stuck turns interrupt first, and a raced "session busy" + * response interrupts + retries through the shared busy gate. + */ +export async function runRewindSubmit( + requestGateway: RequestGateway, + sessionId: string, + text: string, + truncateOrdinal: number | undefined, + interruptFirst: boolean +): Promise<void> { + const interrupt = async () => { + try { + await requestGateway('session.interrupt', { session_id: sessionId }) + } catch { + // Best-effort. The submit path still gates on the gateway state. + } + } + + const submit = () => + requestGateway( + 'prompt.submit', + { session_id: sessionId, text, ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) }, + PROMPT_SUBMIT_REQUEST_TIMEOUT_MS + ) + + if (interruptFirst) { + await interrupt() + } + + try { + await submit() + } catch (err) { + if (!isSessionBusyError(err)) { + throw err + } + + await interrupt() + await withSessionBusyRetry(submit) + } +} + +/** Cancel/stop finalize: drop empty pending/stream placeholders, un-pend the rest. */ +export function finalizeInterruptedMessages(messages: ChatMessage[], streamId?: null | string): ChatMessage[] { + return messages + .filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim())) + .map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message)) +} + +// --------------------------------------------------------------------------- +// Reload (regenerate) +// --------------------------------------------------------------------------- + +export interface ReloadPlan { + branchGroupId: string + text: string + truncateOrdinal: number + userIndex: number +} + +/** The user turn to re-run for a reload from `parentId` (or the last turn). */ +export function planReload(messages: ChatMessage[], parentId: null | string): null | ReloadPlan { + const parentIndex = parentId ? messages.findIndex(m => m.id === parentId) : messages.length - 1 + + const userBack = + parentIndex >= 0 ? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(m => m.role === 'user') : -1 + + if (userBack < 0) { + return null + } + + const userIndex = parentIndex - userBack + const userMessage = messages[userIndex] + const text = userMessage ? chatMessageText(userMessage).trim() : '' + + if (!userMessage || !text) { + return null + } + + const targetAssistant = + parentId && messages[parentIndex]?.role === 'assistant' + ? messages[parentIndex] + : messages.slice(userIndex + 1).find(m => m.role === 'assistant') + + return { + branchGroupId: targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage), + text, + truncateOrdinal: visibleUserOrdinal(messages, userIndex), + userIndex + } +} + +/** Optimistic reload state: keep the user turn, hide the branch's assistants. */ +export function applyReloadOptimistic(state: ClientSessionState, plan: ReloadPlan): ClientSessionState { + const nextUserIndex = state.messages.findIndex((m, i) => i > plan.userIndex && m.role === 'user') + const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex + + return { + ...state, + awaitingResponse: true, + busy: true, + interrupted: false, + messages: [ + ...state.messages.slice(0, plan.userIndex + 1), + ...state.messages + .slice(plan.userIndex + 1, end) + .map(m => (m.role === 'assistant' ? { ...m, branchGroupId: plan.branchGroupId, hidden: true } : m)) + ], + pendingBranchGroup: plan.branchGroupId, + sawAssistantPayload: false + } +} + +// --------------------------------------------------------------------------- +// Restore (rewind checkpoint) +// --------------------------------------------------------------------------- + +export interface RestoreTarget { + text?: string + userOrdinal?: null | number +} + +export interface RestorePlan { + sourceIndex: number + text: string + truncateOrdinal: number +} + +/** Resolve the user turn to rewind to; throws with a user-facing reason. */ +export function planRestore(messages: ChatMessage[], messageId: string, target?: RestoreTarget): RestorePlan { + const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user') + + const fallbackIndex = + target?.userOrdinal === null || target?.userOrdinal === undefined + ? -1 + : visibleUserIndexAtOrdinal(messages, target.userOrdinal) + + const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex + const source = messages[sourceIndex] + + if (!source || source.role !== 'user') { + throw new Error('Could not find the message to restore.') + } + + const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim() + + if (!text) { + throw new Error('Cannot restore an empty message.') + } + + const truncateOrdinal = + target?.userOrdinal === null || target?.userOrdinal === undefined + ? visibleUserOrdinal(messages, sourceIndex) + : target.userOrdinal + + return { sourceIndex, text, truncateOrdinal } +} + +// --------------------------------------------------------------------------- +// Edit (revert + resubmit with new text) +// --------------------------------------------------------------------------- + +export interface EditPlan { + editedMessage: ChatMessage + isFailedTurn: boolean + sourceIndex: number + text: string + truncateOrdinal: number | undefined +} + +/** Resolve the edited user turn, or null when nothing changed / invalid. */ +export function planEdit(messages: ChatMessage[], edited: AppendMessage): EditPlan | null { + const sourceId = edited.sourceId || edited.parentId + const text = appendText(edited) + + if (!sourceId || !text || edited.role !== 'user') { + return null + } + + const sourceIndex = messages.findIndex(m => m.id === sourceId) + const source = messages[sourceIndex] + + if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) { + return null + } + + // Failed turn: the optimistic user msg never reached the gateway, so a + // truncate-by-ordinal would 422 — resubmit plainly instead. + const nextMessage = messages[sourceIndex + 1] + const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error) + + return { + editedMessage: { ...source, parts: [textPart(text)] }, + isFailedTurn, + sourceIndex, + text, + truncateOrdinal: isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex) + } +} + +/** Optimistic rewind-to state for restore/edit: drop everything after the + * source turn (edit swaps in the edited message; restore keeps the original). */ +export function applyRewindOptimistic( + state: ClientSessionState, + sourceIndex: number, + editedMessage?: ChatMessage +): ClientSessionState { + return { + ...state, + awaitingResponse: true, + busy: true, + interrupted: false, + messages: editedMessage + ? [...state.messages.slice(0, sourceIndex), editedMessage] + : state.messages.slice(0, sourceIndex + 1), + pendingBranchGroup: null, + sawAssistantPayload: false + } +} + +// --------------------------------------------------------------------------- +// Branch visibility (assistant-ui hides non-active branches) +// --------------------------------------------------------------------------- + +/** Sync each assistant branch message's `hidden` to what the thread renders. */ +export function applyBranchVisibility(state: ClientSessionState, next: readonly ThreadMessage[]): ClientSessionState { + const visibleIds = new Set(next.map(m => m.id)) + let changed = false + + const messages = state.messages.map(message => { + if (message.role !== 'assistant' || !message.branchGroupId) { + return message + } + + const hidden = !visibleIds.has(message.id) + + if (message.hidden === hidden) { + return message + } + + changed = true + + return { ...message, hidden } + }) + + return changed ? { ...state, messages } : state +} diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 76a25885854c..162d9e410118 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -4,6 +4,7 @@ import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' import type { Translations } from '@/i18n' import { type ChatMessage, textPart } from '@/lib/chat-messages' import { optimisticAttachmentRef } from '@/lib/chat-runtime' +import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { setMutableRef } from '@/lib/mutable-ref' import { $composerAttachments, @@ -21,9 +22,9 @@ import { _submitInFlight, type GatewayRequest, inlineErrorMessage, + isGatewayTimeoutError, isProviderSetupError, isSessionBusyError, - isGatewayTimeoutError, isSessionNotFoundError, type SubmitTextOptions, withSessionBusyRetry @@ -35,6 +36,7 @@ interface SubmitPromptDeps { busyRef: MutableRefObject<boolean> copy: Translations['desktop'] createBackendSessionForSend: (preview?: string | null) => Promise<string | null> + getRouteToken: () => string requestGateway: GatewayRequest selectedStoredSessionIdRef: MutableRefObject<string | null> syncAttachmentsForSubmit: ( @@ -47,6 +49,26 @@ interface SubmitPromptDeps { updater: (state: ClientSessionState) => ClientSessionState, storedSessionId?: string | null ) => ClientSessionState + /** Composer-scope seams: the main chat runs on the module-level globals + * (defaults); a session tile injects its own so a tile submit never writes + * the primary view's $busy/$messages or clears the main attachment chips. */ + scope?: { + clearAttachments: () => void + readAttachments: () => ComposerAttachment[] + setAwaitingResponse: (awaiting: boolean) => void + setBusy: (busy: boolean) => void + setMessages: (updater: (current: ChatMessage[]) => ChatMessage[]) => void + } +} + +// Stable identity — a fresh default object per render would churn the +// useCallback below on every render. +const MAIN_SUBMIT_SCOPE: NonNullable<SubmitPromptDeps['scope']> = { + clearAttachments: clearComposerAttachments, + readAttachments: () => $composerAttachments.get(), + setAwaitingResponse, + setBusy, + setMessages } /** The prompt submit pipeline, extracted from usePromptActions. */ @@ -57,15 +79,17 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { busyRef, copy, createBackendSessionForSend, + getRouteToken, requestGateway, selectedStoredSessionIdRef, syncAttachmentsForSubmit, - updateSessionState + updateSessionState, + scope = MAIN_SUBMIT_SCOPE } = deps return useCallback( async (rawText: string, options?: SubmitTextOptions) => { - const visibleText = rawText.trim() + const visibleText = sanitizeComposerInput(rawText).trim() const usingComposerAttachments = !options?.attachments // Drop undefined/null holes a session switch or draft restore can leave in @@ -73,7 +97,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // this, the sibling iterations below (a.kind / a.label / a.refText, and the // sync step) throw "Cannot read properties of undefined (reading 'refText')" // and break the chat surface. - const attachments = (options?.attachments ?? $composerAttachments.get()).filter((a): a is ComposerAttachment => + const attachments = (options?.attachments ?? scope.readAttachments()).filter((a): a is ComposerAttachment => Boolean(a) ) @@ -113,9 +137,22 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { return false } + // Pin the session context for the whole async submit pipeline. Without + // this, a fast session switch during session.resume / file.attach can + // redirect the user's text into a different chat (#54527). Mutable — + // not const — because a new-chat submit legitimately re-homes to the + // session it creates (see the re-pin after createBackendSessionForSend). + const startingActiveSessionId = activeSessionIdRef.current + let startingStoredSessionId = selectedStoredSessionIdRef.current + let startingRouteToken = getRouteToken() + + const sessionContextDrifted = (): boolean => + selectedStoredSessionIdRef.current !== startingStoredSessionId || + getRouteToken() !== startingRouteToken + // One submit in flight per session — drop any concurrent re-fire so a // stalled turn can't stack the same prompt into multiple real turns. - const submitLockKey = selectedStoredSessionIdRef.current || activeSessionId || '__pending_new__' + const submitLockKey = startingStoredSessionId || startingActiveSessionId || '__pending_new__' if (_submitInFlight.has(submitLockKey)) { return false @@ -143,8 +180,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { const releaseBusy = () => { releaseSubmitLock() setMutableRef(busyRef, false) - setBusy(false) - setAwaitingResponse(false) + scope.setBusy(false) + scope.setAwaitingResponse(false) } // Idempotent optimistic insert — re-running with the resolved sessionId @@ -166,7 +203,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // (what made drained-after-interrupt sends go silent). interrupted: false }), - selectedStoredSessionIdRef.current + startingStoredSessionId ) // After sync rewrites refs, refresh the optimistic message in place so the @@ -178,12 +215,12 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { ...state, messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message)) }), - selectedStoredSessionIdRef.current + startingStoredSessionId ) const dropOptimistic = (sid: null | string) => { if (!sid) { - setMessages(current => current.filter(m => m.id !== optimisticId)) + scope.setMessages(current => current.filter(m => m.id !== optimisticId)) return } @@ -197,13 +234,20 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { awaitingResponse: false, pendingBranchGroup: null }), - selectedStoredSessionIdRef.current + startingStoredSessionId ) } + const abortForSessionSwitch = (optimisticSessionId: null | string): false => { + dropOptimistic(optimisticSessionId) + releaseBusy() + + return false + } + setMutableRef(busyRef, true) - setBusy(true) - setAwaitingResponse(true) + scope.setBusy(true) + scope.setAwaitingResponse(true) clearNotifications() let sessionId: null | string = activeSessionId @@ -211,10 +255,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { if (sessionId) { seedOptimistic(sessionId) } else { - setMessages(current => [...current, buildUserMessage()]) + scope.setMessages(current => [...current, buildUserMessage()]) } - if (!sessionId && selectedStoredSessionIdRef.current) { + if (!sessionId && startingStoredSessionId) { // A stored session is SELECTED but its runtime binding is gone (the // live session was orphan-reaped, or a timeout/reconnect cleared // activeSessionId). Continuing the selected conversation must mean @@ -224,9 +268,13 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // new-chat draft). try { const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: selectedStoredSessionIdRef.current + session_id: startingStoredSessionId }) + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + if (resumed?.session_id) { sessionId = resumed.session_id activeSessionIdRef.current = sessionId @@ -237,6 +285,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // the user's message. } + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + if (sessionId) { seedOptimistic(sessionId) } @@ -254,6 +306,13 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } if (!sessionId) { + // createBackendSessionForSend returns null when the user switched + // sessions mid-create (it closes the orphaned session itself) — + // abort silently. Anything else is a real failure worth a toast. + if (sessionContextDrifted()) { + return abortForSessionSwitch(null) + } + dropOptimistic(null) releaseBusy() notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) @@ -261,6 +320,23 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { return false } + // A successful create re-homes selection + route to the chat it just + // minted, so the pre-create baseline can't tell our own re-home from + // a user switch (judging it drift aborted EVERY first send of a new + // chat: no prompt.submit, no DB row, a stranded route that 404s + // "Session not found"). The drift signal for this window is the + // active ref instead: every switch path re-nulls or retargets it + // synchronously, so it only still equals the id create returned when + // nobody re-homed since. + if (activeSessionIdRef.current !== sessionId) { + return abortForSessionSwitch(sessionId) + } + + // Re-pin the baseline to the created chat for the rest of the + // pipeline; the closures (seedOptimistic et al) see the new value. + startingStoredSessionId = selectedStoredSessionIdRef.current + startingRouteToken = getRouteToken() + seedOptimistic(sessionId) } @@ -269,6 +345,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { updateComposerAttachments: usingComposerAttachments }) + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + // Rewrite the optimistic message + prompt text with the synced refs so // the gateway receives @file: paths that resolve in its workspace. // (Images keep their inline base64 preview — see optimisticAttachmentRef.) @@ -288,7 +368,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } catch (firstErr) { if ( (isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && - selectedStoredSessionIdRef.current + startingStoredSessionId ) { // Re-register the session in the gateway and get a fresh live ID. // Timeouts recover the same way as "session not found": a starved @@ -296,10 +376,14 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // the stored session is fine — resume + retry instead of erroring // out and losing the session binding. const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: selectedStoredSessionIdRef.current, + session_id: startingStoredSessionId, source: 'desktop' }) + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + const recoveredId = resumed?.session_id if (recoveredId) { @@ -320,7 +404,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } if (usingComposerAttachments) { - clearComposerAttachments() + scope.clearAttachments() } // Submit landed — the turn now runs (busy stays true), but the submit @@ -375,7 +459,9 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { busyRef, copy, createBackendSessionForSend, + getRouteToken, requestGateway, + scope, selectedStoredSessionIdRef, syncAttachmentsForSubmit, updateSessionState diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index 1df123824d1e..de501a52bbc7 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -142,7 +142,7 @@ export async function readFileDataUrlForAttach(filePath: string): Promise<string } // The readFileDataUrl IPC base64-loads the whole file into memory and is -// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which +// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.ts, which // rejects with a raw "file is too large (N bytes; limit M bytes)" string. In // remote mode every attachment's bytes go through that read, so a big file // surfaces that internal message verbatim in the failure toast. Translate it diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index e00960de2f2a..bf53209d4f03 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, waitFor } from '@testing-library/react' +import { act, cleanup, render, waitFor } from '@testing-library/react' import type { MutableRefObject } from 'react' import { useEffect } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -6,13 +6,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getSessionMessages, type SessionInfo } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' import { $activeGatewayProfile, $newChatProfile } from '@/store/profile' +import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' import { $activeSessionId, $currentCwd, $messages, + $newChatWorkspaceTarget, $resumeFailedSessionId, setActiveSessionId, + setCurrentCwd, setMessages, + setNewChatWorkspaceTarget, setResumeFailedSessionId, setSessions } from '@/store/session' @@ -31,6 +35,7 @@ vi.mock('@/hermes', async importOriginal => ({ })) const RUNTIME_SESSION_ID = 'rt-new-001' +type HarnessHandle = Pick<ReturnType<typeof useSessionActions>, 'createBackendSessionForSend' | 'startFreshSessionDraft'> function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo { return { @@ -55,7 +60,7 @@ function Harness({ onReady, requestGateway }: { - onReady: (create: (preview?: string | null) => Promise<string | null>) => void + onReady: (handle: HarnessHandle) => void requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> }) { const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value }) @@ -69,6 +74,7 @@ function Harness({ getRouteToken: () => 'token', navigate: vi.fn() as never, requestGateway, + resetViewSync: vi.fn(), runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()), selectedStoredSessionId: null, selectedStoredSessionIdRef: ref<string | null>(null), @@ -78,13 +84,16 @@ function Harness({ }) useEffect(() => { - onReady(actions.createBackendSessionForSend) - }, [actions.createBackendSessionForSend, onReady]) + onReady(actions) + }, [actions, onReady]) return null } -async function createWith(profileSetup: () => void): Promise<Record<string, unknown> | undefined> { +async function createWith( + profileSetup: () => void, + beforeCreate?: (handle: HarnessHandle) => Promise<void> | void +): Promise<Record<string, unknown> | undefined> { let createParams: Record<string, unknown> | undefined const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { @@ -97,13 +106,23 @@ async function createWith(profileSetup: () => void): Promise<Record<string, unkn return {} as never }) - $currentCwd.set('') + setCurrentCwd('') + setNewChatWorkspaceTarget(undefined) profileSetup() - let create: ((preview?: string | null) => Promise<string | null>) | null = null - render(<Harness onReady={c => (create = c)} requestGateway={requestGateway} />) - await waitFor(() => expect(create).not.toBeNull()) - await create!() + let handle: HarnessHandle | null = null + render(<Harness onReady={h => (handle = h)} requestGateway={requestGateway} />) + await waitFor(() => expect(handle).not.toBeNull()) + + if (beforeCreate) { + await act(async () => { + await beforeCreate(handle!) + }) + } + + await act(async () => { + await handle!.createBackendSessionForSend() + }) return createParams } @@ -113,7 +132,10 @@ describe('createBackendSessionForSend profile routing', () => { cleanup() $newChatProfile.set(null) $activeGatewayProfile.set('default') + $projectScope.set(ALL_PROJECTS) + $projectTree.set([]) $currentCwd.set('') + setNewChatWorkspaceTarget(undefined) vi.restoreAllMocks() }) @@ -161,6 +183,24 @@ describe('createBackendSessionForSend profile routing', () => { expect(params).toMatchObject({ cwd: '/remote/worktree' }) }) + + it('falls back to the entered project cwd when the current cwd is blank', async () => { + const params = await createWith(() => { + $projectTree.set([ + { + id: 'p_app', + label: 'App', + path: '/repo/app', + repos: [{ groups: [], id: '/repo/app', label: 'app', path: '/repo/app', sessionCount: 0 }], + sessionCount: 0 + } + ]) + $projectScope.set('p_app') + $currentCwd.set('') + }) + + expect(params).toMatchObject({ cwd: '/repo/app' }) + }) }) // ── Resume failure recovery (the "stuck loading session window" bug) ────────── @@ -190,6 +230,7 @@ function ResumeHarness({ getRouteToken: () => 'token', navigate: vi.fn() as never, requestGateway, + resetViewSync: vi.fn(), runtimeIdByStoredSessionIdRef: runtimeIdByStoredSessionIdRef ?? ref(new Map<string, string>()), selectedStoredSessionId: null, selectedStoredSessionIdRef: ref<string | null>(null), @@ -385,6 +426,7 @@ describe('resumeSession failure recovery', () => { storedSessionId: 'stored-1', streamId: null, turnStartedAt: null, + usage: null, yolo: false } ] @@ -437,6 +479,7 @@ function BranchHarness({ getRouteToken: () => 'token', navigate: vi.fn() as never, requestGateway, + resetViewSync: vi.fn(), runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()), selectedStoredSessionId: null, selectedStoredSessionIdRef: ref<string | null>(null), @@ -592,3 +635,44 @@ describe('resumeSession warm-cache mapping integrity', () => { expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A') }) }) + +describe('createBackendSessionForSend workspace target', () => { + afterEach(() => { + cleanup() + $newChatProfile.set(null) + $activeGatewayProfile.set('default') + setCurrentCwd('') + setNewChatWorkspaceTarget(undefined) + vi.restoreAllMocks() + }) + + it('omits cwd for an explicit no-workspace draft even when global cwd changes before send', async () => { + const params = await createWith( + () => { + $activeGatewayProfile.set('default') + }, + handle => { + handle.startFreshSessionDraft({ workspaceTarget: null }) + $currentCwd.set('/project-open-in-file-browser') + } + ) + + expect(params).not.toHaveProperty('cwd') + expect($newChatWorkspaceTarget.get()).toBeUndefined() + }) + + it('uses the clicked workspace target instead of a later global cwd value', async () => { + const params = await createWith( + () => { + $activeGatewayProfile.set('default') + }, + handle => { + handle.startFreshSessionDraft({ workspaceTarget: '/clicked-workspace' }) + $currentCwd.set('/project-open-in-file-browser') + } + ) + + expect(params).toMatchObject({ cwd: '/clicked-workspace' }) + }) + +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 2b51f002e4b8..96c8c913b56c 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -2,6 +2,7 @@ import type { MutableRefObject } from 'react' import { useCallback, useRef } from 'react' import type { NavigateFunction } from 'react-router-dom' +import { revealTreePane } from '@/components/pane-shell/tree/store' import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes' import { useI18n } from '@/i18n' import { preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' @@ -18,19 +19,23 @@ import { $currentProvider, $currentReasoningEffort, $messages, + $newChatWorkspaceTarget, $sessions, $yoloActive, + type NewChatWorkspaceTarget, sessionPinId, setActiveSessionId, setAwaitingResponse, setBusy, setCurrentBranch, setCurrentCwd, + setCurrentCwdTransient, setCurrentServiceTier, setCurrentUsage, setFreshDraftReady, setIntroSeed, setMessages, + setNewChatWorkspaceTarget, setResumeExhaustedSessionId, setResumeFailedSessionId, setSelectedStoredSessionId, @@ -38,9 +43,9 @@ import { setSessionStartedAt, setSessionsTotal, setTurnStartedAt, - setYoloActive, - workspaceCwdForNewSession + setYoloActive } from '@/store/session' +import { closeSessionTile, dropSessionState, openSessionTile, patchSessionTile, publishSessionState, type TileDock } from '@/store/session-states' import { broadcastSessionsChanged } from '@/store/session-sync' import { isWatchWindow } from '@/store/windows' import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes' @@ -72,6 +77,7 @@ interface SessionActionsOptions { getRouteToken: () => string navigate: NavigateFunction requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> + resetViewSync: () => void runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> selectedStoredSessionId: string | null selectedStoredSessionIdRef: MutableRefObject<string | null> @@ -84,6 +90,63 @@ interface SessionActionsOptions { ) => ClientSessionState } +// Stored ids created in THIS renderer run. A brand-new session lives only in the +// gateway's in-memory map until its first turn persists a state.db row — so if a +// respawning/flapping backend drops it, both resume RPC and the REST transcript +// 404 even though the user just made it. We must NOT treat that as "gone" (which +// yanks them to a fresh draft — the "new sessions clear themselves" bug); the +// bounded retry rebinds it when the backend returns. Boot-into-a-stale-last-id +// (NOT in this set) still legitimately drops to a draft. +const createdThisRun = new Set<string>() + +// Reflect a stored row's persisted token counts into the live usage atom +// (total is derived, so callers can't drift it out of sync with input/output). +function applyStoredUsage(stored: { input_tokens?: number | null; output_tokens?: number | null }) { + const input = stored.input_tokens || 0 + const output = stored.output_tokens || 0 + + setCurrentUsage(current => ({ ...current, input, output, total: input + output })) +} + +// `session.create` params from the current profile + sticky-UI model/effort/fast, +// ensuring the gateway is on that profile first. Shared by the primary send path +// and the "open in split" tile path; `cwd` is the one thing that differs (the +// live composer cwd for a send, the resolved new-session cwd for a fresh tile). +// +// Resolving null profile to the active gateway's is load-bearing: in global-remote +// mode one backend serves every profile, so an omitted profile silently lands the +// chat on the launch (default) profile — the "rubberbands back to default" bug. +// A no-op for single-profile/local-pooled users (a backend resolves its own launch +// profile to None). The sticky UI model/effort/fast ride as per-session overrides, +// never the profile default (that lives in Settings → Model). +async function desktopSessionCreateParams(cwd: string): Promise<Record<string, unknown>> { + const profile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get()) + await ensureGatewayProfile(profile) + + const model = $currentModel.get().trim() + const provider = $currentProvider.get().trim() + const effort = $currentReasoningEffort.get().trim() + + return { + cols: 96, + source: 'desktop', + ...(cwd && { cwd }), + ...(profile ? { profile } : {}), + ...(model ? { model, ...(provider ? { provider } : {}) } : {}), + ...(effort ? { reasoning_effort: effort } : {}), + ...($currentFastMode.get() ? { fast: true } : {}) + } +} + +interface FreshSessionDraftOptions { + replaceRoute?: boolean + workspaceTarget?: NewChatWorkspaceTarget +} + +function normalizeNewChatWorkspaceTarget(target: NewChatWorkspaceTarget): NewChatWorkspaceTarget { + return typeof target === 'string' ? target.trim() || null : target +} + export function useSessionActions({ activeSessionId, activeSessionIdRef, @@ -93,6 +156,7 @@ export function useSessionActions({ getRouteToken, navigate, requestGateway, + resetViewSync, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, selectedStoredSessionIdRef, @@ -105,7 +169,18 @@ export function useSessionActions({ const resumeRequestRef = useRef(0) const startFreshSessionDraft = useCallback( - (replaceRoute = false) => { + (options: boolean | FreshSessionDraftOptions = false) => { + const draftOptions = typeof options === 'boolean' ? { replaceRoute: options } : options + const replaceRoute = draftOptions.replaceRoute ?? false + + const hasWorkspaceTarget = + Object.hasOwn(draftOptions, 'workspaceTarget') && draftOptions.workspaceTarget !== undefined + + const workspaceTarget = hasWorkspaceTarget + ? normalizeNewChatWorkspaceTarget(draftOptions.workspaceTarget) + : undefined + + resetViewSync() busyRef.current = false setBusy(false) setAwaitingResponse(false) @@ -133,15 +208,23 @@ export function useSessionActions({ // is cleared. setCurrentServiceTier('') setYoloActive(false) - // In a project → the repo's default-branch (main worktree) checkout; not in - // a project → detached. So cmd-n "knows" the project instead of inheriting - // whatever linked worktree the last session drifted into. - setCurrentCwd(resolveNewSessionCwd()) + setNewChatWorkspaceTarget(hasWorkspaceTarget ? workspaceTarget : undefined) + + if (!hasWorkspaceTarget) { + // In a project → the repo's default-branch checkout; not in a project → + // detached. So cmd-n does not inherit an unrelated linked worktree. + setCurrentCwd(resolveNewSessionCwd()) + } else if (workspaceTarget === null) { + setCurrentCwdTransient('') + } else if (typeof workspaceTarget === 'string') { + setCurrentCwd(workspaceTarget) + } + setCurrentBranch('') // Never clear the composer here — ChatBar's per-thread draft swap owns it. setFreshDraftReady(true) }, - [activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef] + [activeSessionIdRef, busyRef, navigate, resetViewSync, selectedStoredSessionIdRef] ) const createBackendSessionForSend = useCallback( @@ -153,37 +236,20 @@ export function useSessionActions({ creatingSessionRef.current = true try { - // A plain new session (top "New Session", /new, keybind) leaves - // $newChatProfile null to mean "use the live context"; the per-profile - // "+" sets it explicitly. Resolve null to the active gateway profile so - // session.create always carries it: in global-remote mode one backend - // serves every profile, so an omitted profile param silently lands the - // chat on the launch (default) profile — the "rubberbands back to - // default" bug. This is a no-op for single-profile/local-pooled users: - // a backend resolves its own launch profile to None (_profile_home). - const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get()) - await ensureGatewayProfile(newChatProfile) - const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession() - // The composer's model/effort/fast is sticky UI state ($currentModel, - // $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it - // with every session.create so the new chat opens on whatever the picker - // shows — applied as per-session overrides, never written to the profile - // default (that lives in Settings → Model). - const uiModel = $currentModel.get().trim() - const uiProvider = $currentProvider.get().trim() - const uiEffort = $currentReasoningEffort.get().trim() - const uiFast = $currentFastMode.get() + // An explicit one-shot workspace target (null → detached, string → that + // folder) wins; otherwise the live cwd, then the project-aware default + // (resolveNewSessionCwd — a project's new session keeps its repo cwd). + const workspaceTarget = $newChatWorkspaceTarget.get() - const created = await requestGateway<SessionCreateResponse>('session.create', { - cols: 96, - source: 'desktop', - ...(cwd && { cwd }), - ...(newChatProfile ? { profile: newChatProfile } : {}), - ...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}), - ...(uiEffort ? { reasoning_effort: uiEffort } : {}), - ...(uiFast ? { fast: true } : {}) - }) + const cwd = + workspaceTarget === null + ? '' + : typeof workspaceTarget === 'string' + ? workspaceTarget.trim() + : $currentCwd.get().trim() || resolveNewSessionCwd() + const params = await desktopSessionCreateParams(cwd) + const created = await requestGateway<SessionCreateResponse>('session.create', params) const stored = created.stored_session_id ?? null if ( @@ -196,11 +262,13 @@ export function useSessionActions({ return null } + resetViewSync() activeSessionIdRef.current = created.session_id selectedStoredSessionIdRef.current = stored ensureSessionState(created.session_id, stored) if (stored) { + createdThisRun.add(stored) // Seed the sidebar preview with the user's first message so the row // reads meaningfully while the turn is in flight, instead of flashing // "Untitled session" until the turn persists and auto-title runs. The @@ -213,6 +281,7 @@ export function useSessionActions({ } setFreshDraftReady(false) + setNewChatWorkspaceTarget(undefined) setActiveSessionId(created.session_id) setSelectedStoredSessionId(stored) setSessionStartedAt(Date.now()) @@ -243,6 +312,7 @@ export function useSessionActions({ getRouteToken, navigate, requestGateway, + resetViewSync, selectedStoredSessionIdRef, updateSessionState ] @@ -263,6 +333,44 @@ export function useSessionActions({ [navigate, startFreshSessionDraft] ) + /** Create a fresh session and open it as a tile — leaves the primary chat alone. + * Used by the New session row's "Open in split" menu (and any future + * "new chat beside" affordance). */ + const openNewSessionTile = useCallback( + async (dir: TileDock = 'right') => { + try { + // Fresh tile → the resolved new-session cwd (project/default), not the + // primary composer's live cwd. + const params = await desktopSessionCreateParams(resolveNewSessionCwd().trim()) + const created = await requestGateway<SessionCreateResponse>('session.create', params) + const stored = created.stored_session_id + + if (!stored) { + await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined) + notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) + + return + } + + createdThisRun.add(stored) + // Seed the sidebar + per-runtime cache, but DON'T steal the primary + // selection — this session lives in the tile. Prime it with the create + // runtime so the tile skips a redundant resume. + upsertOptimisticSession(created, stored, null, null) + const runtimeInfo = applyRuntimeInfo(created.info) + updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored) + + openSessionTile(stored, dir) + patchSessionTile(stored, { runtimeId: created.session_id }) + revealTreePane(`session-tile:${stored}`) + broadcastSessionsChanged() + } catch (error) { + notifyError(error, copy.createSessionFailed) + } + }, + [copy, requestGateway, updateSessionState] + ) + const openSettings = useCallback(() => { navigate(SETTINGS_ROUTE) }, [navigate]) @@ -295,6 +403,7 @@ export function useSessionActions({ // resume entry"). setFreshDraftReady(false) clearNotifications() + resetViewSync() setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId // Optimistically clear any prior resume-failure latch for this session: @@ -327,6 +436,7 @@ export function useSessionActions({ if (state.storedSessionId !== storedSessionId) { runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) sessionStateByRuntimeIdRef.current.delete(runtimeId) + dropSessionState(runtimeId) return null } @@ -375,11 +485,13 @@ export function useSessionActions({ if (cachedViewState !== cachedState) { sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState) + publishSessionState(cachedRuntimeId, cachedViewState) } if (sessionShouldHaveTranscript(stored) && cachedViewState.messages.length === 0) { runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId) + dropSessionState(cachedRuntimeId) } else { setFreshDraftReady(false) clearNotifications() @@ -416,6 +528,7 @@ export function useSessionActions({ runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId) + dropSessionState(cachedRuntimeId) } } } @@ -423,6 +536,16 @@ export function useSessionActions({ setFreshDraftReady(false) setActiveSessionId(null) activeSessionIdRef.current = null + // A warm-cache hit at entry skipped the cold-path transcript clear, but the + // warm path can still bail down to here — an empty-transcript drop, or the + // cache getting purged during the profile-swap await — so the PREVIOUS + // session's transcript would leak into this cold resume ("switching + // sessions shows the same messages"). Clear it so the loader/prefetch + // paints fresh; guarded so the normal cold path (already cleared) no-ops. + if ($messages.get().length > 0) { + setMessages([]) + } + busyRef.current = true setBusy(true) setAwaitingResponse(false) @@ -437,12 +560,7 @@ export function useSessionActions({ applyStoredSessionPreviewRuntimeInfo(stored) if (stored) { - setCurrentUsage(current => ({ - ...current, - input: stored.input_tokens || 0, - output: stored.output_tokens || 0, - total: (stored.input_tokens || 0) + (stored.output_tokens || 0) - })) + applyStoredUsage(stored) } let resumedRunning = false @@ -594,6 +712,16 @@ export function useSessionActions({ // permanently-dead id. (Booting straight into a no-longer-existent // last-session id is the common trigger.) if ($messages.get().length === 0 && isSessionGoneError(fallbackError)) { + // A session created THIS run isn't gone — its backend just flapped + // before the turn-less session persisted. Keep the empty view and arm + // the bounded retry to rebind, rather than yanking to a fresh draft. + // Only a stale id from a PRIOR run drops to a draft. + if (createdThisRun.has(storedSessionId)) { + setResumeFailedSessionId(storedSessionId) + + return + } + startFreshSessionDraft(true) return @@ -625,6 +753,7 @@ export function useSessionActions({ busyRef, copy, requestGateway, + resetViewSync, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef, @@ -827,6 +956,18 @@ export function useSessionActions({ if (closingRuntimeId) { clearQueuedPrompts(closingRuntimeId) } + + // A tiled copy of this session must not outlive it: collapse the pane + // and evict its mirrored runtime state so nothing submits to (or renders) + // a deleted session. + const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) + closeSessionTile(storedSessionId) + + if (tiledRuntimeId) { + runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) + sessionStateByRuntimeIdRef.current.delete(tiledRuntimeId) + dropSessionState(tiledRuntimeId) + } } catch (err) { if (removed) { setSessions(prev => [removed, ...prev]) @@ -843,12 +984,7 @@ export function useSessionActions({ const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) if (stored) { - setCurrentUsage(current => ({ - ...current, - input: stored.input_tokens || 0, - output: stored.output_tokens || 0, - total: (stored.input_tokens || 0) + (stored.output_tokens || 0) - })) + applyStoredUsage(stored) } setMessages(previousMessages) @@ -869,8 +1005,10 @@ export function useSessionActions({ copy, navigate, requestGateway, + runtimeIdByStoredSessionIdRef, selectedStoredSessionId, selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, startFreshSessionDraft ] ) @@ -907,6 +1045,16 @@ export function useSessionActions({ // not appear to do nothing until the next full refresh. setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))) $pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId)) + // An archived session is hidden from the sidebar; its tile must go too. + const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) + closeSessionTile(storedSessionId) + + if (tiledRuntimeId) { + runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) + sessionStateByRuntimeIdRef.current.delete(tiledRuntimeId) + dropSessionState(tiledRuntimeId) + } + notify({ durationMs: 2_000, kind: 'success', message: copy.archived }) } catch (err) { if (archived) { @@ -919,7 +1067,13 @@ export function useSessionActions({ notifyError(err, copy.archiveFailed) } }, - [copy, selectedStoredSessionId, startFreshSessionDraft] + [ + copy, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + sessionStateByRuntimeIdRef, + startFreshSessionDraft + ] ) return { @@ -928,6 +1082,7 @@ export function useSessionActions({ branchStoredSession, closeSettings, createBackendSessionForSend, + openNewSessionTile, openSettings, removeSession, resumeSession, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index 680cc754286e..7ddc66679687 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -1,9 +1,12 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import type { ChatMessage } from '@/lib/chat-messages' +import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode' +import { $activeGatewayProfile } from '@/store/profile' import type { SessionInfo } from '@/types/hermes' import { + applyRuntimeInfo, chatMessageArraysEquivalent, isSessionGoneError, reconcileResumeMessages, @@ -17,6 +20,20 @@ const msg = (id: string, role: ChatMessage['role'], text: string, extra: Partial const session = (over: Partial<SessionInfo>): SessionInfo => over as SessionInfo +describe('applyRuntimeInfo approval mode', () => { + beforeEach(() => { + $approvalModes.set({}) + $activeGatewayProfile.set('work') + }) + + it('reconciles session.info against the gateway profile', () => { + applyRuntimeInfo({ approval_mode: 'smart', desktop_contract: 3 }) + + expect(approvalModeForProfile('work')).toBe('smart') + expect(approvalModeForProfile('default')).toBe('smart') + }) +}) + describe('isSessionGoneError', () => { it('is true for 404 / session-not-found, false otherwise', () => { expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index d299fe51b7e4..f202997ef1fc 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -2,11 +2,13 @@ import { getSession } from '@/hermes' import { type ChatMessage, chatMessageText } from '@/lib/chat-messages' import { normalizePersonalityValue } from '@/lib/chat-runtime' import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images' +import { reconcileApprovalModeForProfile } from '@/store/approval-mode' import { requestDesktopOnboarding } from '@/store/onboarding' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $currentCwd, $sessions, + sessionMatchesStoredId, setCurrentBranch, setCurrentCwd, setCurrentFastMode, @@ -19,6 +21,10 @@ import { setSessions, setYoloActive } from '@/store/session' + +// Re-exported for the many session-actions/tile call sites that already import +// it from here; the canonical definition lives in @/store/session. +export { sessionMatchesStoredId } import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates' import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes' @@ -182,10 +188,6 @@ export function patchSessionWorkspace(sessionId: string, cwd: string | undefined setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session))) } -export function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean { - return session.id === storedSessionId || session._lineage_root_id === storedSessionId -} - export function sessionShouldHaveTranscript(session: SessionInfo | undefined): boolean { return (session?.message_count ?? 0) > 0 } @@ -266,6 +268,10 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR reportBackendContract(info.desktop_contract) + if (info.approval_mode !== undefined) { + reconcileApprovalModeForProfile($activeGatewayProfile.get(), info.approval_mode) + } + if (info.credential_warning) { requestDesktopOnboarding(info.credential_warning) } diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index 6f971c3165be..6e4cbb7cc021 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -1,6 +1,7 @@ import { useCallback, useRef } from 'react' import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes' +import { sameCronSignature } from '@/lib/session-signatures' import { isMessagingSource, LOCAL_SESSION_SOURCE_IDS, @@ -29,8 +30,6 @@ import { setSessionsTotal } from '@/store/session' -import { sameCronSignature } from '../../desktop-controller-utils' - // The recents list is local-only: cron rows have their own section, and each // messaging platform (telegram, discord, …) is fetched separately into its own // self-managed sidebar section (refreshMessagingSessions). Excluding both here diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 3f8e02c8ca8a..7f89eec64f9e 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -21,6 +21,7 @@ import { setTurnStartedAt, setYoloActive } from '@/store/session' +import { publishSessionState } from '@/store/session-states' import type { ClientSessionState } from '../../types' @@ -130,6 +131,18 @@ export function useSessionStateCache({ return created }, []) + const resetViewSync = useCallback(() => { + // Drop any RAF-pending transcript stage so a backgrounded turn cannot + // repaint over the chat the user just switched to (#47709 / #47743). + pendingViewStateRef.current = null + viewSessionIdRef.current = null + + if (viewSyncRafRef.current !== null && typeof window !== 'undefined') { + window.cancelAnimationFrame(viewSyncRafRef.current) + viewSyncRafRef.current = null + } + }, []) + const flushPendingViewState = useCallback(() => { const pending = pendingViewStateRef.current pendingViewStateRef.current = null @@ -251,6 +264,10 @@ export function useSessionStateCache({ const previous = ensureSessionState(sessionId, storedSessionId) const next = updater({ ...previous, messages: previous.messages }) sessionStateByRuntimeIdRef.current.set(sessionId, next) + // Mirror into the reactive multi-session store — session tiles (and any + // other non-primary surface) subscribe per runtime id there instead of + // through the single active $messages view. + publishSessionState(sessionId, next) if (previous.storedSessionId !== next.storedSessionId || !next.busy) { setSessionWorking(previous.storedSessionId, false) @@ -306,6 +323,7 @@ export function useSessionStateCache({ return { activeSessionIdRef, ensureSessionState, + resetViewSync, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef, diff --git a/apps/desktop/src/app/session/workspace-session-target.test.ts b/apps/desktop/src/app/session/workspace-session-target.test.ts new file mode 100644 index 000000000000..3a83f605a321 --- /dev/null +++ b/apps/desktop/src/app/session/workspace-session-target.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + $currentBranch, + $currentCwd, + $newChatWorkspaceTarget, + setCurrentBranch, + setCurrentCwd, + setNewChatWorkspaceTarget +} from '@/store/session' + +import { startWorkspaceSession } from './workspace-session-target' + +function deferred<T>() { + let resolve!: (value: T) => void + + const promise = new Promise<T>(done => { + resolve = done + }) + + return { promise, resolve } +} + +describe('startWorkspaceSession', () => { + afterEach(() => { + setCurrentBranch('') + setCurrentCwd('') + setNewChatWorkspaceTarget(undefined) + vi.restoreAllMocks() + }) + + it('keeps a newer sidebar target when an older project lookup resolves', async () => { + const first = deferred<{ branch?: string; cwd?: string }>() + const second = deferred<{ branch?: string; cwd?: string }>() + + const requestGateway = vi + .fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + + const activeSessionIdRef = { current: null } + + const startFreshSessionDraft = vi.fn((options?: { workspaceTarget: string }) => { + setNewChatWorkspaceTarget(options?.workspaceTarget) + setCurrentCwd(options?.workspaceTarget || '') + }) + + const followActiveSessionCwd = vi.fn() + + startWorkspaceSession({ + activeSessionIdRef, + followActiveSessionCwd, + path: '/workspace-a', + requestGateway, + startFreshSessionDraft + }) + startWorkspaceSession({ + activeSessionIdRef, + followActiveSessionCwd, + path: '/workspace-b', + requestGateway, + startFreshSessionDraft + }) + + first.resolve({ branch: 'stale', cwd: '/normalized-a' }) + await first.promise + await Promise.resolve() + + expect($newChatWorkspaceTarget.get()).toBe('/workspace-b') + expect($currentCwd.get()).toBe('/workspace-b') + expect($currentBranch.get()).not.toBe('stale') + + second.resolve({ branch: 'main', cwd: '/normalized-b' }) + await second.promise + await Promise.resolve() + + expect($newChatWorkspaceTarget.get()).toBe('/normalized-b') + expect($currentCwd.get()).toBe('/normalized-b') + expect($currentBranch.get()).toBe('main') + }) +}) diff --git a/apps/desktop/src/app/session/workspace-session-target.ts b/apps/desktop/src/app/session/workspace-session-target.ts new file mode 100644 index 000000000000..da5028502a11 --- /dev/null +++ b/apps/desktop/src/app/session/workspace-session-target.ts @@ -0,0 +1,64 @@ +import type { MutableRefObject } from 'react' + +import { followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects' +import { + $newChatWorkspaceTargetGeneration, + setCurrentBranch, + setCurrentCwd, + setNewChatWorkspaceTarget +} from '@/store/session' + +interface WorkspaceSessionOptions { + activeSessionIdRef: MutableRefObject<string | null> + followActiveSessionCwd?: (cwd: string) => void | Promise<void> + onExplicitWorkspace?: (cwd: string) => void + path: null | string + requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> + startFreshSessionDraft: (options?: { workspaceTarget: string }) => void +} + +export function startWorkspaceSession({ + activeSessionIdRef, + followActiveSessionCwd: followCwd = followActiveSessionCwd, + onExplicitWorkspace, + path, + requestGateway, + startFreshSessionDraft +}: WorkspaceSessionOptions): void { + // A worktree lane carries its own path; a project trunk can be path-less, so + // fall back to the active project's root for that existing controller path. + const explicitTarget = path?.trim() + const target = explicitTarget || resolveNewSessionCwd() + + startFreshSessionDraft(target ? { workspaceTarget: target } : undefined) + + if (!target) { + return + } + + const workspaceGeneration = $newChatWorkspaceTargetGeneration.get() + + setCurrentCwd(target) + void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) + .then(info => { + if ($newChatWorkspaceTargetGeneration.get() !== workspaceGeneration || activeSessionIdRef.current) { + return + } + + const resolved = info.cwd || target + + setCurrentCwd(resolved) + setNewChatWorkspaceTarget(resolved) + setCurrentBranch(info.branch || '') + + if (explicitTarget) { + onExplicitWorkspace?.(resolved) + void followCwd(resolved) + } + }) + .catch(() => { + if ($newChatWorkspaceTargetGeneration.get() === workspaceGeneration && !activeSessionIdRef.current) { + setCurrentBranch('') + } + }) +} diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 559978e41568..32deb34d2d47 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -12,6 +12,7 @@ import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' +import { $backdrop, setBackdrop } from '@/store/backdrop' import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' @@ -248,6 +249,7 @@ export function AppearanceSettings() { const embedMode = useStore($embedMode) const embedAllowed = useStore($embedAllowed) const translucency = useStore($translucency) + const backdrop = useStore($backdrop) const installs = useStore($marketplaceInstalls) const profiles = useStore($profiles) const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile)) @@ -451,6 +453,24 @@ export function AppearanceSettings() { title={a.translucencyTitle} /> + <ListRow + action={ + <SegmentedControl + onChange={id => { + triggerHaptic('selection') + setBackdrop(id === 'on') + }} + options={[ + { id: 'off', label: t.common.off }, + { id: 'on', label: t.common.on } + ]} + value={backdrop ? 'on' : 'off'} + /> + } + description={a.backdropDesc} + title={a.backdropTitle} + /> + <ListRow action={ <SegmentedControl diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index cac0d197f2ce..46732d21bd14 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -19,12 +19,13 @@ import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' import { PanelEmpty } from '../overlays/panel' import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS } from './constants' +import { FallbackModelsField } from './fallback-models-field' import { fieldCopyForSchemaKey } from './field-copy' import { enumOptionsFor, getNested, prettyName, sectionFieldEntries, setNested } from './helpers' import { MemoryConnect } from './memory/connect' +import { ProviderConfigPanel } from './memory/provider-config-panel' import { ModelSettings, ModelSettingsSkeleton } from './model-settings' import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives' -import { ProviderConfigPanel } from './memory/provider-config-panel' // On the Voice page, only surface the sub-fields of the *selected* TTS/STT // provider — otherwise every provider's options render at once (the "totally @@ -100,6 +101,13 @@ function ConfigField({ <ListRow action={action} description={descriptionNode} title={label} wide={wide} /> ) + // `fallback_providers` is a list of {provider, model} objects; the generic + // `list` branch below would stringify them to "[object Object]". Render the + // dedicated structured editor instead. + if (schemaKey === 'fallback_providers') { + return row(<FallbackModelsField onChange={onChange} value={value} />, true) + } + if (schema.type === 'boolean') { return row( <div className="flex items-center justify-end"> diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index d73692de6376..112ab75c9a9c 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -237,7 +237,7 @@ export const ENUM_OPTIONS: Record<string, string[]> = { 'approvals.mode': ['manual', 'smart', 'off'], 'code_execution.mode': ['project', 'strict'], 'context.engine': ['compressor', 'default', 'custom'], - 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'], + 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], 'memory.provider': ['', 'builtin', 'honcho', 'hindsight'], // Terminal execution backends — kept in sync with the dispatch ladder in // tools/terminal_tool.py::_create_environment (local/docker/singularity/ diff --git a/apps/desktop/src/app/settings/fallback-models-field.test.tsx b/apps/desktop/src/app/settings/fallback-models-field.test.tsx new file mode 100644 index 000000000000..1f2401010e61 --- /dev/null +++ b/apps/desktop/src/app/settings/fallback-models-field.test.tsx @@ -0,0 +1,128 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +// Radix Select calls scrollIntoView / pointer-capture APIs jsdom lacks. +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + Element.prototype.releasePointerCapture = vi.fn() +}) + +const getGlobalModelOptions = vi.fn() + +vi.mock('@/hermes', () => ({ + getGlobalModelOptions: () => getGlobalModelOptions() +})) + +beforeEach(() => { + getGlobalModelOptions.mockResolvedValue({ + providers: [ + { name: 'GitHub Copilot', slug: 'copilot', models: ['gpt-5-mini', 'gpt-5.4-mini'] }, + { name: 'OpenAI Codex', slug: 'openai-codex', models: ['gpt-5.4-mini'] }, + { name: 'Nous', slug: 'nous', models: ['hermes-4'] } + ] + }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +async function renderField(value: unknown, onChange = vi.fn()) { + const { FallbackModelsField } = await import('./fallback-models-field') + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + render( + <QueryClientProvider client={client}> + <FallbackModelsField onChange={onChange} value={value} /> + </QueryClientProvider> + ) + + return onChange +} + +async function renderFieldWithRerender(value: unknown, onChange = vi.fn()) { + const { FallbackModelsField } = await import('./fallback-models-field') + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const view = render( + <QueryClientProvider client={client}> + <FallbackModelsField onChange={onChange} value={value} /> + </QueryClientProvider> + ) + + return (next: unknown) => + view.rerender( + <QueryClientProvider client={client}> + <FallbackModelsField onChange={onChange} value={next} /> + </QueryClientProvider> + ) +} + +const CHAIN = [ + { provider: 'copilot', model: 'gpt-5-mini' }, + { provider: 'openai-codex', model: 'gpt-5.4-mini' } +] + +describe('FallbackModelsField', () => { + it('renders each {provider, model} entry as its own row (never "[object Object]")', async () => { + await renderField(CHAIN) + + // One Remove control per entry proves the object list became rows — the old + // generic `list` input stringified the array to "[object Object]". + expect(screen.getAllByLabelText('Remove')).toHaveLength(2) + expect(screen.getByText('Add fallback')).toBeTruthy() + expect(screen.queryByText(/\[object Object\]/)).toBeNull() + await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled()) + }) + + it('removing a row emits the remaining entries', async () => { + const onChange = await renderField(CHAIN) + + fireEvent.click(screen.getAllByLabelText('Remove')[0]) + + expect(onChange.mock.calls.at(-1)?.[0]).toEqual([{ provider: 'openai-codex', model: 'gpt-5.4-mini' }]) + }) + + it('adding a blank row does not persist a partial entry', async () => { + const onChange = await renderField(CHAIN) + + fireEvent.click(screen.getByText('Add fallback')) + + // The new empty row stays in the UI but only complete pairs are emitted. + expect(onChange.mock.calls.at(-1)?.[0]).toEqual(CHAIN) + expect(screen.getAllByLabelText('Remove')).toHaveLength(3) + }) + + it('shows an empty-state hint when there are no fallbacks', async () => { + await renderField([]) + + expect(screen.getByText(/No fallback models/)).toBeTruthy() + expect(screen.queryAllByLabelText('Remove')).toHaveLength(0) + }) + + it('resyncs rows when persisted config changes', async () => { + const rerender = await renderFieldWithRerender(CHAIN) + expect(screen.getAllByLabelText('Remove')).toHaveLength(2) + + rerender([{ provider: 'nous', model: 'hermes-4' }]) + + await waitFor(() => expect(screen.getAllByLabelText('Remove')).toHaveLength(1)) + }) + + it('keeps a draft row visible after autosave re-renders the same persisted chain', async () => { + const onChange = vi.fn() + const rerender = await renderFieldWithRerender([], onChange) + + fireEvent.click(screen.getByText('Add fallback')) + + expect(onChange.mock.calls.at(-1)?.[0]).toEqual([]) + expect(screen.getAllByLabelText('Remove')).toHaveLength(1) + + // Parent autosave echo — same complete chain, new array identity. + rerender([]) + + await waitFor(() => expect(screen.getAllByLabelText('Remove')).toHaveLength(1)) + }) +}) diff --git a/apps/desktop/src/app/settings/fallback-models-field.tsx b/apps/desktop/src/app/settings/fallback-models-field.tsx new file mode 100644 index 000000000000..0254492ae075 --- /dev/null +++ b/apps/desktop/src/app/settings/fallback-models-field.tsx @@ -0,0 +1,164 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { getGlobalModelOptions } from '@/hermes' +import { useI18n } from '@/i18n' +import { Plus, X } from '@/lib/icons' +import { cn } from '@/lib/utils' + +import { CONTROL_TEXT } from './constants' + +interface FallbackEntry { + provider: string + model: string +} + +// Normalize the raw config value (`fallback_providers`: a list of +// `{provider, model}` dicts) into editor rows. Defensive against legacy string +// entries ("provider/model") so the editor never crashes on odd data. +function normalizeEntries(value: unknown): FallbackEntry[] { + if (!Array.isArray(value)) { + return [] + } + + return value.map(item => { + if (item && typeof item === 'object') { + const record = item as Record<string, unknown> + + return { provider: String(record.provider ?? ''), model: String(record.model ?? '') } + } + + if (typeof item === 'string') { + const slash = item.indexOf('/') + + return slash > 0 ? { provider: item.slice(0, slash), model: item.slice(slash + 1) } : { provider: '', model: item } + } + + return { provider: '', model: '' } + }) +} + +function completeEntries(rows: FallbackEntry[]): FallbackEntry[] { + return rows.filter(entry => entry.provider && entry.model) +} + +function entriesEqual(a: FallbackEntry[], b: FallbackEntry[]): boolean { + return a.length === b.length && a.every((entry, index) => entry.provider === b[index]?.provider && entry.model === b[index]?.model) +} + +/** + * Structured editor for the top-level `fallback_providers` config list — a + * chain of `{provider, model}` pairs tried in order when the default model + * fails. Replaces the generic comma-string `list` input, which stringified the + * objects to "[object Object], [object Object]". + * + * Mirrors the Auxiliary Models picker in `model-settings.tsx`: provider + model + * selects sourced from `getGlobalModelOptions()`. Half-filled rows are kept in + * local state and only complete pairs are emitted upward, so the config + * autosave never persists a partial `{provider, model: ''}`. + */ +export function FallbackModelsField({ + value, + onChange +}: { + value: unknown + onChange: (next: FallbackEntry[]) => void +}) { + const { t } = useI18n() + const m = t.settings.model + + const modelOptions = useQuery({ + queryKey: ['model-options', 'global'], + queryFn: () => getGlobalModelOptions() + }) + + const providers = (modelOptions.data?.providers ?? []).filter(provider => provider.slug) + + const [rows, setRows] = useState<FallbackEntry[]>(() => normalizeEntries(value)) + // Last complete chain we emitted (or seeded). Autosave echoes the same + // filtered list back through `value`; ignore that echo so draft rows stay. + const lastEmittedRef = useRef(normalizeEntries(value)) + + // Resync on real external changes (profile switch / config reload). Skip + // when `value` is just our own commit echoing through the parent. + useEffect(() => { + const persisted = normalizeEntries(value) + + if (entriesEqual(persisted, lastEmittedRef.current)) { + return + } + + lastEmittedRef.current = persisted + setRows(persisted) + }, [value]) + + const commit = (next: FallbackEntry[]) => { + const complete = completeEntries(next) + + setRows(next) + lastEmittedRef.current = complete + onChange(complete) + } + + const updateRow = (index: number, patch: Partial<FallbackEntry>) => + commit(rows.map((entry, i) => (i === index ? { ...entry, ...patch } : entry))) + + return ( + <div className="grid w-full gap-1.5"> + {rows.length === 0 && <p className="text-xs text-muted-foreground">{m.fallbackEmpty}</p>} + {rows.map((entry, index) => { + const providerRow = providers.find(provider => provider.slug === entry.provider) + const catalog = providerRow?.models ?? [] + // Keep an out-of-catalog model selectable so an existing custom + // provider/model renders instead of showing a blank box. + const modelItems = entry.model && !catalog.includes(entry.model) ? [entry.model, ...catalog] : catalog + + return ( + <div className="flex flex-wrap items-center gap-2" key={index}> + <span className="w-4 shrink-0 text-center font-mono text-[0.7rem] text-muted-foreground">{index + 1}</span> + <Select onValueChange={provider => updateRow(index, { provider, model: '' })} value={entry.provider}> + <SelectTrigger className={cn('min-w-36', CONTROL_TEXT)}> + <SelectValue placeholder={m.provider} /> + </SelectTrigger> + <SelectContent> + {providers.map(provider => ( + <SelectItem key={provider.slug} value={provider.slug}> + {provider.name} + </SelectItem> + ))} + </SelectContent> + </Select> + <Select onValueChange={model => updateRow(index, { model })} value={entry.model}> + <SelectTrigger className={cn('min-w-52 flex-1', CONTROL_TEXT)}> + <SelectValue placeholder={m.model} /> + </SelectTrigger> + <SelectContent> + {modelItems.map(model => ( + <SelectItem key={model} value={model}> + {model} + </SelectItem> + ))} + </SelectContent> + </Select> + <Button + aria-label={t.common.remove} + onClick={() => commit(rows.filter((_, i) => i !== index))} + size="icon-xs" + variant="ghost" + > + <X className="size-3.5" /> + </Button> + </div> + ) + })} + <div> + <Button onClick={() => commit([...rows, { provider: '', model: '' }])} size="sm" variant="textStrong"> + <Plus className="size-3.5" /> + {m.fallbackAdd} + </Button> + </div> + </div> + ) +} diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index aae1c75efe29..f7743203e5dc 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -3,9 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global' +import { Tip } from '@/components/ui/tooltip' +import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global' import { useI18n } from '@/i18n' -import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons' +import { ExternalLink } from '@/lib/external-link' +import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons' +import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { $profiles, refreshActiveProfile } from '@/store/profile' @@ -13,9 +16,11 @@ import { $profiles, refreshActiveProfile } from '@/store/profile' import { CONTROL_TEXT } from './constants' import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives' -type Mode = 'local' | 'remote' +type Mode = 'local' | 'remote' | 'cloud' type AuthMode = 'oauth' | 'token' type ProbeStatus = 'idle' | 'probing' | 'done' | 'error' +// Hermes Cloud discovery lifecycle for the cloud-mode panel. +type CloudDiscoverStatus = 'idle' | 'loading' | 'done' | 'error' interface GatewaySettingsState { envOverride: boolean @@ -25,6 +30,7 @@ interface GatewaySettingsState { remoteTokenPreview: string | null remoteTokenSet: boolean remoteUrl: string + cloudOrg: string } const EMPTY_STATE: GatewaySettingsState = { @@ -34,13 +40,15 @@ const EMPTY_STATE: GatewaySettingsState = { remoteOauthConnected: false, remoteTokenPreview: null, remoteTokenSet: false, - remoteUrl: '' + remoteUrl: '', + cloudOrg: '' } function ModeCard({ active, description, disabled, + hint, icon: Icon, onSelect, title @@ -48,6 +56,7 @@ function ModeCard({ active: boolean description: string disabled?: boolean + hint?: string icon: typeof Monitor onSelect: () => void title: string @@ -55,22 +64,29 @@ function ModeCard({ return ( <button className={cn( - 'rounded-xl border p-3 text-left transition', - active - ? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)' - : 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)', - disabled && 'cursor-not-allowed opacity-50' + 'flex h-full min-h-0 w-full flex-col p-3 text-left disabled:cursor-not-allowed disabled:opacity-50', + selectableCardClass({ active, prominent: true }) )} disabled={disabled} onClick={onSelect} type="button" > - <div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium"> - <Icon className="size-4 text-muted-foreground" /> - <span>{title}</span> - {active ? <Check className="ml-auto size-4 text-primary" /> : null} + <div className="flex items-center gap-1.5"> + <Icon className="size-3.5 shrink-0 text-muted-foreground" /> + <span className="min-w-0 text-[length:var(--conversation-text-font-size)] font-medium">{title}</span> + {hint ? ( + <Tip label={hint}> + <span + className="grid size-3.5 shrink-0 cursor-help place-items-center text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)" + onClick={event => event.stopPropagation()} + > + <HelpCircle className="size-3.5" /> + </span> + </Tip> + ) : null} + {active ? <Check className="ml-auto size-3.5 shrink-0 text-primary" /> : null} </div> - <p className="mt-1.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + <p className="mt-1.5 flex-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> {description} </p> </button> @@ -94,7 +110,11 @@ function ScopeChip({ active, label, onSelect }: { active: boolean; label: string ) } -export function GatewaySettings() { +// `embedded` trims the page chrome for reuse inside the boot-failure recovery +// card: the outer title/intro, the "Save for next restart" action, and the +// Diagnostics row are redundant there (the card owns its header + a single +// reconnect action), so only the connection controls render. +export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {}) { const { t } = useI18n() const g = t.settings.gateway const [loading, setLoading] = useState(true) @@ -105,6 +125,32 @@ export function GatewaySettings() { const [remoteToken, setRemoteToken] = useState('') const [lastTest, setLastTest] = useState<null | string>(null) + // --- Hermes Cloud (cloud mode) state --- + // One portal session powers discovery + the silent per-agent cascade. These + // track the cloud panel: whether we're signed in, the discovered agent list, + // and which agent is mid-connect. + const [cloudSignedIn, setCloudSignedIn] = useState(false) + const [cloudSigningIn, setCloudSigningIn] = useState(false) + const [cloudAgents, setCloudAgents] = useState<DesktopCloudAgent[]>([]) + const [cloudDiscover, setCloudDiscover] = useState<CloudDiscoverStatus>('idle') + const [cloudConnectingId, setCloudConnectingId] = useState<null | string>(null) + // Multi-org users: when discovery returns needsOrgSelection, we hold the org + // list here and show a picker. `cloudOrg` is the chosen org slug/id (null = + // not yet chosen / single-org user). + const [cloudOrgs, setCloudOrgs] = useState<DesktopCloudOrg[]>([]) + const [cloudOrg, setCloudOrgState] = useState<null | string>(null) + // Mirror the selected org into a ref so connect reads the CURRENT value, not a + // value captured in a stale render closure. discoverCloud() resolves the org + // asynchronously (from the NAS response) and a user can click Connect in the + // same render tick; without the ref, connectCloudAgent could persist a null + // org even though discovery just resolved one. Always set both together. + const cloudOrgRef = useRef<null | string>(null) + + const setCloudOrg = (value: null | string) => { + cloudOrgRef.current = value + setCloudOrgState(value) + } + // Connection scope: null = the global/default connection (the original // behavior); a profile name = that profile's per-profile remote override, so // each profile can point at its own backend. @@ -163,6 +209,22 @@ export function GatewaySettings() { // OAuth login button or the session-token entry box. The effective auth mode // prefers a fresh probe result over the saved value. const trimmedUrl = state.remoteUrl.trim() + + // The dashboardUrl of the currently-connected cloud instance (the saved + // cloud connection's remoteUrl), normalized for comparison against each + // discovered agent's dashboardUrl so we can highlight the active one and hide + // its Connect button. Empty unless the saved connection is a cloud one. + // The saved cloud URL was stored via the main-side normalizeRemoteBaseUrl + // (which lowercases the host through URL.toString()), but a discovered agent's + // dashboardUrl arrives raw from NAS — so normalize both sides the same way + // (trim, drop trailing slash, lowercase) or a host-casing difference would + // silently break the connected-highlight. + const normalizeCloudUrl = (url: string) => url.trim().replace(/\/+$/, '').toLowerCase() + const connectedCloudUrl = state.mode === 'cloud' ? normalizeCloudUrl(state.remoteUrl) : '' + + const isConnectedAgent = (agent: DesktopCloudAgent) => + Boolean(connectedCloudUrl && agent.dashboardUrl && normalizeCloudUrl(agent.dashboardUrl) === connectedCloudUrl) + useEffect(() => { if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) { setProbeStatus('idle') @@ -379,6 +441,234 @@ export function GatewaySettings() { } } + // --- Hermes Cloud handlers --- + + // Pull the discovered agent list over the shared portal session. Tolerant of + // a lapsed session: a needsCloudLogin error flips us back to signed-out. + // `org` scopes discovery for multi-org users; when discovery comes back with + // needsOrgSelection we surface the org list and show a picker instead. + const discoverCloud = async (org?: string) => { + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudDiscover('loading') + + try { + const result = await desktop.cloud.discover(org) + + if ('needsOrgSelection' in result && result.needsOrgSelection) { + // Multi-org user with no org chosen yet: show the picker. Don't clear a + // previously-chosen org list on a refresh. + setCloudOrgs(result.orgs) + setCloudAgents([]) + setCloudDiscover('done') + + return + } + + // Single org (or org now chosen): we have agents. + setCloudAgents('agents' in result ? result.agents : []) + + // Record the org AUTHORITATIVELY from the response (NAS echoes the org the + // list was scoped to), falling back to the org we requested. This is what + // gets persisted on connect, so it must be set even on single-membership + // auto-resolve where no picker ran and no `org` arg was passed. + const resolvedOrgRef = 'org' in result && result.org ? (result.org.slug ?? result.org.id) : null + + if (resolvedOrgRef) { + setCloudOrg(resolvedOrgRef) + } else if (org) { + setCloudOrg(org) + } + + setCloudDiscover('done') + } catch (err) { + setCloudAgents([]) + setCloudDiscover('error') + + // A lapsed/absent portal session means we're effectively signed out. + if (err && typeof err === 'object' && 'needsCloudLogin' in err) { + setCloudSignedIn(false) + } + + notifyError(err, g.cloudDiscoverFailed) + } + } + + // User picked an org from the multi-org picker: remember it and re-run + // discovery scoped to it. + const selectCloudOrg = (org: DesktopCloudOrg) => { + const ref = org.slug ?? org.id + setCloudOrg(ref) + void discoverCloud(ref) + } + + // "Change org": clear the selected org and re-discover with no org arg. A + // multi-org user gets NAS's 409 → the picker; a single-org user auto-resolves + // back to their one org. Also clear the agent list so the current org's + // agents don't linger under the picker while discovery re-runs. + const changeCloudOrg = () => { + setCloudOrg(null) + setCloudAgents([]) + void discoverCloud() + } + + // On entering cloud mode (or scope change), read the portal session status and + // auto-discover when already signed in, so the picker is populated on open. + useEffect(() => { + if (state.mode !== 'cloud') { + return + } + + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + let cancelled = false + desktop.cloud + .status() + .then(status => { + if (cancelled) { + return + } + + setCloudSignedIn(status.signedIn) + + if (status.signedIn) { + // Restore the persisted org (if any) so we reopen straight into that + // org's agent list instead of the picker; discoverCloud(org) also + // records it as the selected org. Empty → normal discovery (single-org + // resolves automatically; multi-org shows the picker). + const savedOrg = state.cloudOrg || '' + + if (savedOrg) { + setCloudOrg(savedOrg) + } + + void discoverCloud(savedOrg || undefined) + } else { + setCloudAgents([]) + setCloudOrgs([]) + setCloudOrg(null) + setCloudDiscover('idle') + } + }) + .catch(() => { + if (!cancelled) { + setCloudSignedIn(false) + } + }) + + return () => void (cancelled = true) + // eslint-disable-next-line react-hooks/exhaustive-deps -- reload on mode/scope change only + }, [state.mode, scope]) + + const cloudSignIn = async () => { + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudSigningIn(true) + + try { + const result = await desktop.cloud.login() + setCloudSignedIn(result.signedIn) + + if (result.signedIn) { + await discoverCloud() + } + } catch (err) { + notifyError(err, g.cloudSignInFailed) + } finally { + setCloudSigningIn(false) + } + } + + const cloudSignOut = async () => { + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudSigningIn(true) + + try { + await desktop.cloud.logout() + setCloudSignedIn(false) + setCloudAgents([]) + setCloudOrgs([]) + setCloudOrg(null) + setCloudDiscover('idle') + notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage }) + } catch (err) { + notifyError(err, g.signOutFailed) + } finally { + setCloudSigningIn(false) + } + } + + // Select a discovered agent: drive the silent per-agent cascade (no second + // prompt — the shared portal session auto-approves), then persist a cloud-mode + // connection pointed at its dashboardUrl and apply it (soft-reconnects in place). + const connectCloudAgent = async (agent: DesktopCloudAgent) => { + if (!agent.dashboardUrl) { + return + } + + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudConnectingId(agent.id) + + try { + const result = await desktop.cloud.agentSignIn(agent.dashboardUrl) + + if (!result.connected) { + notify({ + kind: 'warning', + title: t.boot.failure.signInIncompleteTitle, + message: t.boot.failure.signInIncompleteMessage + }) + + return + } + + // Persist a cloud-mode connection (remote-shaped, oauth) and soft-reconnect. + // Include the selected org so Settings reopens into the same org + instance. + // Read the REF (not the cloudOrg state) so a just-resolved org from + // discovery in this same render tick is captured, not a stale null. + const next = await desktop.applyConnectionConfig({ + mode: 'cloud', + profile: scope ?? undefined, + remoteAuthMode: 'oauth', + remoteUrl: agent.dashboardUrl, + cloudOrg: cloudOrgRef.current ?? undefined + }) + + setState(next) + notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) }) + } catch (err) { + if (err && typeof err === 'object' && 'needsCloudLogin' in err) { + setCloudSignedIn(false) + } + + notifyError(err, g.cloudConnectFailed) + } finally { + setCloudConnectingId(null) + } + } + const testRemote = async () => { if (!canUseRemote) { notify({ @@ -421,17 +711,19 @@ export function GatewaySettings() { } return ( - <SettingsContent> - <div className="mb-5"> - <div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium"> - <Globe className="size-4 text-muted-foreground" /> - {g.title} - {state.envOverride ? <Pill tone="primary">{g.envOverride}</Pill> : null} + <SettingsContent bare={embedded}> + {embedded ? null : ( + <div className="mb-5"> + <div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium"> + <Globe className="size-4 text-muted-foreground" /> + {g.title} + {state.envOverride ? <Pill tone="primary">{g.envOverride}</Pill> : null} + </div> + <p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> + {g.intro} + </p> </div> - <p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> - {g.intro} - </p> - </div> + )} {namedProfiles.length > 0 ? ( <div className="mb-5 grid gap-2"> @@ -465,144 +757,323 @@ export function GatewaySettings() { </div> ) : null} - <div className="grid gap-3 sm:grid-cols-2"> - <ModeCard - active={state.mode === 'local'} - description={g.localDesc} - disabled={state.envOverride} - icon={Monitor} - onSelect={() => setState(current => ({ ...current, mode: 'local' }))} - title={g.localTitle} - /> - <ModeCard - active={state.mode === 'remote'} - description={g.remoteDesc} - disabled={state.envOverride} - icon={Globe} - onSelect={() => setState(current => ({ ...current, mode: 'remote' }))} - title={g.remoteTitle} - /> + <div className="mb-5 grid gap-2"> + <div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)"> + {g.modeTitle} + </div> + <div className="grid auto-rows-fr grid-cols-1 gap-2 min-[42rem]:grid-cols-3"> + <ModeCard + active={state.mode === 'local'} + description={g.localDesc} + disabled={state.envOverride} + icon={Monitor} + onSelect={() => setState(current => ({ ...current, mode: 'local' }))} + title={g.localTitle} + /> + <ModeCard + active={state.mode === 'cloud'} + description={g.cloudDesc} + disabled={state.envOverride} + icon={Cloud} + onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))} + title={g.cloudTitle} + /> + <ModeCard + active={state.mode === 'remote'} + description={g.remoteDesc} + disabled={state.envOverride} + hint={g.remoteAuthHint} + icon={Globe} + onSelect={() => setState(current => ({ ...current, mode: 'remote' }))} + title={g.remoteTitle} + /> + </div> </div> - <div className="mt-5 grid gap-1"> - <ListRow - action={ - <Input - className={cn('h-8', CONTROL_TEXT)} - disabled={state.envOverride} - onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))} - placeholder="https://gateway.example.com/hermes" - value={state.remoteUrl} - /> - } - description={g.remoteUrlDesc} - title={g.remoteUrlTitle} - /> - - {state.mode === 'remote' && probeStatus === 'probing' ? ( - <div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> - <Loader2 className="size-4 animate-spin" /> - {g.probing} - </div> - ) : null} - - {state.mode === 'remote' && probeStatus === 'error' ? ( - <div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> - <AlertCircle className="mt-0.5 size-4 shrink-0" /> - {g.probeError} - </div> - ) : null} - - {/* OAuth / password gateways: present a sign-in button + connection status. */} - {state.mode === 'remote' && authResolved && authMode === 'oauth' ? ( + {/* Hermes Cloud panel: one portal sign-in, then a discovered-agent picker + whose selection drives the silent per-agent cascade + a cloud + connection. Replaces the URL/token form while in cloud mode. */} + {state.mode === 'cloud' && !state.envOverride ? ( + <div className="mt-5 grid gap-1"> <ListRow action={ - oauthConnected ? ( + cloudSignedIn ? ( <div className="flex items-center gap-2"> <Pill tone="primary"> - <Check className="size-3" /> {g.signedIn} + <Check className="size-3" /> {g.cloudSignedIn} </Pill> - <Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline"> - {signingIn ? <Loader2 className="animate-spin" /> : null} + <Button disabled={cloudSigningIn} onClick={() => void cloudSignOut()} variant="outline"> + {cloudSigningIn ? <Loader2 className="animate-spin" /> : null} {g.signOut} </Button> </div> ) : ( - <Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}> - {signingIn ? <Loader2 className="animate-spin" /> : <LogIn />} - {isPasswordProvider ? g.signIn : g.signInWith(providerLabel)} + <Button disabled={cloudSigningIn} onClick={() => void cloudSignIn()}> + {cloudSigningIn ? <Loader2 className="animate-spin" /> : <LogIn />} + {g.cloudSignIn} </Button> ) } - description={ - oauthConnected - ? isPasswordProvider - ? g.authSignedInPassword - : g.authSignedInOauth - : isPasswordProvider - ? g.authNeedsPassword - : g.authNeedsOauth(providerLabel) - } - title={g.authTitle} + description={cloudSignedIn ? g.cloudSignedInDesc : g.cloudNeedsSignIn} + title={g.cloudSignInTitle} /> - ) : null} - {/* Session-token gateways: keep the existing token entry box. */} - {state.mode === 'remote' && authResolved && authMode === 'token' ? ( + {cloudSignedIn ? ( + cloudOrgs.length > 0 && !cloudOrg ? ( + // Multi-org user who hasn't picked an org yet: show the org picker + // instead of the agent list. Selecting one re-runs discovery + // scoped to it. + <div className="mt-3"> + <div className="mb-2 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)"> + {g.cloudOrgPickerTitle} + </div> + <div className="grid gap-1"> + {cloudOrgs.map(orgEntry => ( + <ListRow + action={ + <Button onClick={() => selectCloudOrg(orgEntry)} size="sm"> + {g.cloudOrgSelect} + </Button> + } + description={g.cloudOrgRole(orgEntry.role)} + key={orgEntry.id} + title={orgEntry.name} + /> + ))} + </div> + </div> + ) : ( + <div className="mt-3"> + <div className="mb-2 flex items-center justify-between"> + <div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)"> + {g.cloudAgentsTitle} + </div> + <div className="flex items-center gap-2"> + {cloudOrg ? ( + // Let the user switch orgs. Gating on cloudOrgs.length would + // hide this after a restore-open (which discovers straight + // into the saved org and never populates the org list). So + // show it whenever an org is selected: clicking clears the + // org and re-runs discovery with no org arg — a multi-org + // user gets the picker (NAS 409), a single-org user simply + // auto-resolves back to their one org (harmless). + <Button onClick={() => changeCloudOrg()} size="sm" variant="text"> + {g.cloudOrgChange} + </Button> + ) : null} + <Button + disabled={cloudDiscover === 'loading'} + onClick={() => void discoverCloud(cloudOrg ?? undefined)} + size="sm" + variant="text" + > + {cloudDiscover === 'loading' ? <Loader2 className="animate-spin" /> : <RefreshCw />} + {g.cloudRefresh} + </Button> + </div> + </div> + + {cloudDiscover === 'loading' ? ( + <div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> + <Loader2 className="size-4 animate-spin" /> + {g.cloudLoadingAgents} + </div> + ) : cloudAgents.length === 0 ? ( + <div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> + <AlertCircle className="mt-0.5 size-4 shrink-0" /> + <span> + {g.cloudNoAgents.before} + <ExternalLink href="https://portal.nousresearch.com/agents" showExternalIcon={false}> + {g.cloudNoAgents.linkText} + </ExternalLink> + {g.cloudNoAgents.after} + </span> + </div> + ) : ( + <div className="grid gap-1"> + {cloudAgents.map(agent => { + const connected = isConnectedAgent(agent) + + return ( + <div + className={cn('rounded-md px-2', connected && 'bg-primary/5 ring-1 ring-primary/25')} + key={agent.id} + > + <ListRow + action={ + connected ? ( + <Pill tone="primary"> + <Check className="mr-1 inline size-3" /> + {g.cloudConnectedPill} + </Pill> + ) : ( + <Button + disabled={!agent.dashboardUrl || cloudConnectingId !== null} + onClick={() => void connectCloudAgent(agent)} + size="sm" + > + {cloudConnectingId === agent.id ? <Loader2 className="animate-spin" /> : null} + {agent.dashboardUrl + ? cloudConnectingId === agent.id + ? g.cloudConnecting + : g.cloudConnect + : g.cloudAgentProvisioning} + </Button> + ) + } + description={g.cloudStatusLabel(agent.dashboardGatewayState)} + title={agent.name} + /> + </div> + ) + })} + </div> + )} + </div> + ) + ) : null} + </div> + ) : null} + + {state.mode === 'remote' && !state.envOverride ? ( + <div className="mt-5 grid gap-1"> <ListRow action={ <Input - autoComplete="off" - className={cn('h-8 font-mono', CONTROL_TEXT)} + className={cn('h-8', CONTROL_TEXT)} disabled={state.envOverride} - onChange={event => setRemoteToken(event.target.value)} - placeholder={ - state.remoteTokenSet ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) : g.pasteSessionToken - } - type="password" - value={remoteToken} + onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))} + placeholder="https://gateway.example.com/hermes" + value={state.remoteUrl} /> } - description={g.tokenDesc} - title={g.tokenTitle} + description={g.remoteUrlDesc} + title={g.remoteUrlTitle} /> - ) : null} - </div> + + {state.mode === 'remote' && probeStatus === 'probing' ? ( + <div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> + <Loader2 className="size-4 animate-spin" /> + {g.probing} + </div> + ) : null} + + {state.mode === 'remote' && probeStatus === 'error' ? ( + <div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> + <AlertCircle className="mt-0.5 size-4 shrink-0" /> + {g.probeError} + </div> + ) : null} + + {/* OAuth / password gateways: present a sign-in button + connection status. */} + {state.mode === 'remote' && authResolved && authMode === 'oauth' ? ( + <ListRow + action={ + oauthConnected ? ( + <div className="flex items-center gap-2"> + <Pill tone="primary"> + <Check className="size-3" /> {g.signedIn} + </Pill> + <Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline"> + {signingIn ? <Loader2 className="animate-spin" /> : null} + {g.signOut} + </Button> + </div> + ) : ( + <Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}> + {signingIn ? <Loader2 className="animate-spin" /> : <LogIn />} + {isPasswordProvider ? g.signIn : g.signInWith(providerLabel)} + </Button> + ) + } + description={ + oauthConnected + ? isPasswordProvider + ? g.authSignedInPassword + : g.authSignedInOauth + : isPasswordProvider + ? g.authNeedsPassword + : g.authNeedsOauth(providerLabel) + } + title={g.authTitle} + /> + ) : null} + + {/* Session-token gateways: keep the existing token entry box. */} + {state.mode === 'remote' && authResolved && authMode === 'token' ? ( + <ListRow + action={ + <Input + autoComplete="off" + className={cn('h-8 font-mono', CONTROL_TEXT)} + disabled={state.envOverride} + onChange={event => setRemoteToken(event.target.value)} + placeholder={ + state.remoteTokenSet + ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) + : g.pasteSessionToken + } + type="password" + value={remoteToken} + /> + } + description={g.tokenDesc} + title={g.tokenTitle} + /> + ) : null} + </div> + ) : null} {lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null} - <div className="mt-6 flex flex-wrap items-center justify-end gap-4"> - <Button - className="mr-auto" - disabled={state.envOverride || testing || !canUseRemote} - onClick={() => void testRemote()} - size="sm" - variant="text" - > - {testing ? <Loader2 className="animate-spin" /> : null} - {g.testRemote} - </Button> - <Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong"> - {g.saveForRestart} - </Button> - <Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm"> - {saving ? <Loader2 className="animate-spin" /> : null} - {g.saveAndReconnect} - </Button> - </div> - - <div className="mt-6 grid gap-1"> - <ListRow - action={ - <Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong"> - <FileText /> - {g.openLogs} + {/* Test/Save apply to local + remote. Cloud connects via the agent picker + above (which applies a cloud connection on select), so its only + bottom-row action would be redundant — hidden in cloud mode. */} + {state.mode !== 'cloud' ? ( + <div className="mt-6 flex flex-wrap items-center justify-end gap-4"> + {state.mode === 'remote' ? ( + <Button + className="mr-auto" + disabled={state.envOverride || testing || !canUseRemote} + onClick={() => void testRemote()} + size="sm" + variant="text" + > + {testing ? <Loader2 className="animate-spin" /> : null} + {g.testRemote} </Button> - } - description={g.diagnosticsDesc} - title={g.diagnostics} - /> - </div> + ) : null} + {embedded ? null : ( + <Button + disabled={state.envOverride || saving} + onClick={() => void save(false)} + size="sm" + variant="textStrong" + > + {g.saveForRestart} + </Button> + )} + <Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm"> + {saving ? <Loader2 className="animate-spin" /> : null} + {g.saveAndReconnect} + </Button> + </div> + ) : null} + + {embedded ? null : ( + <div className="mt-6 grid gap-1"> + <ListRow + action={ + <Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong"> + <FileText /> + {g.openLogs} + </Button> + } + description={g.diagnosticsDesc} + title={g.diagnostics} + /> + </div> + )} </SettingsContent> ) } diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 9c7c4dd0b4e1..6f6662056cd4 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -6,7 +6,7 @@ import { Tip } from '@/components/ui/tooltip' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons' +import { Archive, Bell, Download, Globe, Info, KeyRound, Package, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons' import { notifyError } from '@/store/notifications' import { useRouteEnumParam } from '../hooks/use-route-enum-param' @@ -22,6 +22,7 @@ import { SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings' import { NotificationsSettings } from './notifications-settings' +import { PluginsSettings } from './plugins-settings' import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings' import { SessionsSettings } from './sessions-settings' import type { SettingsPageProps, SettingsView as SettingsViewId } from './types' @@ -32,6 +33,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 'gateway', 'keys', 'notifications', + 'plugins', 'sessions', 'about' ] @@ -186,6 +188,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set label: t.settings.nav.apiKeys, onSelect: () => setActiveView('keys') }, + { + active: activeView === 'plugins', + icon: Package, + id: 'plugins', + label: t.settings.nav.plugins, + onSelect: () => setActiveView('plugins') + }, { active: activeView === 'sessions', icon: Archive, @@ -259,6 +268,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set <KeysSettings view={keysView} /> ) : activeView === 'notifications' ? ( <NotificationsSettings /> + ) : activeView === 'plugins' ? ( + <PluginsSettings /> ) : ( <SessionsSettings /> )} diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 135e9e268f2f..3f81d238f33e 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -32,7 +33,8 @@ vi.mock('@/hermes', () => ({ saveMoaModels: (body: unknown) => saveMoaModels(body), setEnvVar: (key: string, value: string) => setEnvVar(key, value), getHermesConfigRecord: () => getHermesConfigRecord(), - saveHermesConfig: (config: unknown) => saveHermesConfig(config) + saveHermesConfig: (config: unknown) => saveHermesConfig(config), + setApiRequestProfile: () => {} })) vi.mock('@/store/onboarding', () => ({ @@ -71,8 +73,13 @@ afterEach(() => { async function renderModelSettings() { const { ModelSettings } = await import('./model-settings') + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - return render(<ModelSettings />) + return render( + <QueryClientProvider client={client}> + <ModelSettings /> + </QueryClientProvider> + ) } describe('ModelSettings', () => { @@ -179,3 +186,139 @@ describe('ModelSettings', () => { expect(await screen.findByText(/still run on/)).toBeTruthy() }) }) + +describe('ModelSettings MoA preset editor', () => { + const moaConfig = () => ({ + default_preset: 'default', + active_preset: '', + presets: { + default: { + reference_models: [ + { provider: 'nous', model: 'hermes-4' }, + { provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' } + ], + aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' }, + reference_temperature: 0, + aggregator_temperature: 0, + max_tokens: 4096, + enabled: true + } + }, + reference_models: [ + { provider: 'nous', model: 'hermes-4' }, + { provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' } + ], + aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' }, + reference_temperature: 0, + aggregator_temperature: 0, + max_tokens: 4096, + enabled: true + }) + + beforeEach(() => { + getGlobalModelOptions.mockResolvedValue({ + providers: [ + { + name: 'Nous', + slug: 'nous', + models: ['hermes-4', 'hermes-4-mini'], + authenticated: true, + capabilities: { 'hermes-4': { reasoning: true, fast: true } } + }, + { + name: 'OpenRouter', + slug: 'openrouter', + models: ['deepseek/deepseek-v4-pro', 'anthropic/claude-opus-4.8'], + authenticated: true + } + ] + }) + getMoaModels.mockResolvedValue(moaConfig()) + saveMoaModels.mockImplementation((body: unknown) => Promise.resolve(body)) + }) + + async function openReferenceEditor() { + await renderModelSettings() + expect(await screen.findByText('Reference 1')).toBeTruthy() + } + + function slotSelects() { + // Combobox order in the MoA section (last 7 on the page): preset select, + // then provider+model per reference (2 refs), then aggregator + // provider+model. Reference 1's pair is therefore at -6 / -5. + const all = screen.getAllByRole('combobox') + + return { ref1Provider: all.at(-6)!, ref1Model: all.at(-5)! } + } + + it('holds the autosave while a slot is half-filled (provider changed, model pending)', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + try { + await openReferenceEditor() + + fireEvent.click(slotSelects().ref1Provider) + fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' })) + + // Model was cleared by the provider change → config incomplete → the + // debounced autosave must NOT fire, even well past the 600ms window. + await vi.advanceTimersByTimeAsync(2000) + expect(saveMoaModels).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('saves once the model pick completes the slot', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + try { + await openReferenceEditor() + + fireEvent.click(slotSelects().ref1Provider) + fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' })) + await vi.advanceTimersByTimeAsync(700) + + fireEvent.click(slotSelects().ref1Model) + fireEvent.click(await screen.findByRole('option', { name: 'anthropic/claude-opus-4.8' })) + await vi.advanceTimersByTimeAsync(700) + + expect(saveMoaModels).toHaveBeenCalledTimes(1) + const sent = saveMoaModels.mock.calls[0][0] as ReturnType<typeof moaConfig> + expect(sent.presets.default.reference_models[0]).toEqual({ + provider: 'openrouter', + model: 'anthropic/claude-opus-4.8' + }) + // The untouched slots ride along unchanged — nothing reverts to defaults. + expect(sent.presets.default.reference_models[1]).toEqual({ + provider: 'openrouter', + model: 'deepseek/deepseek-v4-pro' + }) + expect(sent.presets.default.aggregator).toEqual({ + provider: 'openrouter', + model: 'anthropic/claude-opus-4.8' + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not clear the model or save when the same provider is re-selected', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + try { + await openReferenceEditor() + + fireEvent.click(slotSelects().ref1Provider) + fireEvent.click(await screen.findByRole('option', { name: 'Nous' })) + await vi.advanceTimersByTimeAsync(700) + + // Radix treats re-picking the current value as a no-op (no + // onValueChange), so nothing changes: no save, model still shown. + expect(saveMoaModels).not.toHaveBeenCalled() + expect(screen.getByText('nous · hermes-4')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 01c57f9925b6..f11820a9f529 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -82,7 +82,7 @@ export function ModelSettingsSkeleton() { // Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off. // Empty config = Hermes default (medium), shown as Medium. -const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const +const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const // agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is // normal (mirrors tui_gateway _load_service_tier). @@ -93,8 +93,8 @@ const isFastTier = (tier: unknown): boolean => .toLowerCase() ) -// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1). -const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal' +// Reuse the composer's effort labels. +const effortLabelKey = (v: string) => v as 'high' | 'low' | 'max' | 'medium' | 'minimal' | 'ultra' | 'xhigh' // A provider row is "ready" to pick a model from when it reports models. The // backend now surfaces the full `hermes model` universe (every canonical @@ -130,6 +130,24 @@ const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', m export const withActive = (models: readonly string[], active: string): readonly string[] => active && !models.includes(active) ? [active, ...models] : models +// A slot is complete when both halves are chosen. Changing a slot's provider +// intentionally clears its model (see updateMoaSlot), so every provider change +// passes through an incomplete state while the user picks the new model. +export const moaSlotComplete = (slot: MoaModelSlot): boolean => !!(slot.provider.trim() && slot.model.trim()) + +// True when every slot in every preset is fully specified — the only state +// that is safe to persist. The backend rejects configs with half-filled slots +// (HTTP 422) instead of silently swapping the preset for hardcoded defaults +// (#64156), so the autosave must simply wait for the edit to finish rather +// than trying to "repair" the payload. +export const moaConfigComplete = (config: MoaConfigResponse): boolean => + Object.values(config.presets).every( + preset => + preset.reference_models.length > 0 && + preset.reference_models.every(moaSlotComplete) && + moaSlotComplete(preset.aggregator) + ) + interface StaleAuxWarningProps { applying: boolean onReset: () => void @@ -298,29 +316,96 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { return moa.presets[selectedMoaPreset] || moa.presets[moa.default_preset] || Object.values(moa.presets)[0] || null }, [moa, selectedMoaPreset]) + // Mirror of `moa` so inline edits compute the next state purely (outside the + // setState updater) and hand it straight to the debounced autosave. + const moaRef = useRef<MoaConfigResponse | null>(null) + + useEffect(() => { + moaRef.current = moa + }, [moa]) + + const moaSaveTimer = useRef<number | null>(null) + + useEffect( + () => () => { + if (moaSaveTimer.current) { + window.clearTimeout(moaSaveTimer.current) + } + }, + [] + ) + + // Guard against stale save responses overwriting newer state. + const moaSaveGeneration = useRef(0) + + // Quiet debounced persist for inline MoA edits — mirrors the config page's + // autosave so slot/aggregator tweaks save themselves, matching the + // preset-level ops (set default / add / delete) that already persist on + // click. No `applying` spinner, so selecting stays responsive. + // + // While any slot is half-filled (provider picked, model pending) the save is + // HELD, not sent: the previous complete config stays on disk and the next + // edit that completes the slot flushes the whole preset. Every edit bumps + // the generation so an in-flight response from an older save can never + // repaint over the user's mid-edit state. + const scheduleMoaSave = useCallback((next: MoaConfigResponse) => { + if (moaSaveTimer.current) { + window.clearTimeout(moaSaveTimer.current) + moaSaveTimer.current = null + } + + const generation = moaSaveGeneration.current + 1 + moaSaveGeneration.current = generation + + if (!moaConfigComplete(next)) { + return + } + + moaSaveTimer.current = window.setTimeout(() => { + void saveMoaModels(next) + .then(saved => { + if (moaSaveGeneration.current === generation) { + setMoa(saved) + } + }) + .catch(err => { + if (moaSaveGeneration.current === generation) { + setError(err instanceof Error ? err.message : String(err)) + } + }) + }, 600) + }, []) + const updateMoaPreset = useCallback( (updater: (preset: NonNullable<typeof currentMoaPreset>) => NonNullable<typeof currentMoaPreset>) => { - setMoa(prev => { - if (!prev || !selectedMoaPreset || !prev.presets[selectedMoaPreset]) { - return prev - } + const prev = moaRef.current - return { - ...prev, - presets: { - ...prev.presets, - [selectedMoaPreset]: updater(prev.presets[selectedMoaPreset]) - } + if (!prev || !selectedMoaPreset || !prev.presets[selectedMoaPreset]) { + return + } + + const next: MoaConfigResponse = { + ...prev, + presets: { + ...prev.presets, + [selectedMoaPreset]: updater(prev.presets[selectedMoaPreset]) } - }) + } + + moaRef.current = next + setMoa(next) + scheduleMoaSave(next) }, - [selectedMoaPreset] + [scheduleMoaSave, selectedMoaPreset] ) const updateMoaSlot = useCallback((slot: MoaModelSlot, patch: Partial<MoaModelSlot>): MoaModelSlot => { const next = { ...slot, ...patch } - if (patch.provider) { + // Picking a new provider invalidates the model choice (models are + // per-provider). A same-provider update must not wipe the model — Radix + // filters same-value changes, but programmatic callers may not. + if (patch.provider && patch.provider !== slot.provider) { next.model = '' } @@ -329,6 +414,16 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { const saveMoa = useCallback(async (next: MoaConfigResponse) => { const epoch = profileEpoch.current + + // Explicit preset ops (set default / add / delete) supersede any pending + // debounced slot autosave — cancel it and invalidate in-flight responses + // so the two writers can't race each other's state. + if (moaSaveTimer.current) { + window.clearTimeout(moaSaveTimer.current) + moaSaveTimer.current = null + } + + moaSaveGeneration.current += 1 setApplying(true) setError('') @@ -841,12 +936,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { </section> {moa && currentMoaPreset && ( <section> - <div className="mb-2.5 flex items-center justify-between"> - <SectionHeading icon={Cpu} title="Mixture of Agents" /> - <Button disabled={applying} onClick={() => void saveMoa(moa)} size="sm" variant="textStrong"> - {applying ? m.applying : t.common.save} - </Button> - </div> + <SectionHeading icon={Cpu} title="Mixture of Agents" /> <p className="mb-2 text-xs text-muted-foreground"> Configure named presets that appear as models under the Mixture of Agents provider. The aggregator is the acting model. @@ -957,11 +1047,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { <SelectValue placeholder={m.provider} /> </SelectTrigger> <SelectContent> - {moaSlotProviderOptions.map(provider => ( - <SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}> - {provider.name} - </SelectItem> - ))} + {withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), slot.provider).map(slug => { + const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug) + + return ( + <SelectItem key={slug} value={slug}> + {provider?.name || slug} + </SelectItem> + ) + })} </SelectContent> </Select> <Select @@ -1003,10 +1097,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { } description={ <span className="font-mono text-[0.68rem]"> - {slot.provider} · {slot.model} + {slot.provider} · {slot.model || m.model} </span> } - key={`${selectedMoaPreset}-${slot.provider}-${slot.model}-${index}`} + key={`${selectedMoaPreset}-${index}`} title={`Reference ${index + 1}`} /> ))} @@ -1036,11 +1130,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { <SelectValue placeholder={m.provider} /> </SelectTrigger> <SelectContent> - {moaSlotProviderOptions.map(provider => ( - <SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}> - {provider.name} - </SelectItem> - ))} + {withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), currentMoaPreset.aggregator.provider).map(slug => { + const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug) + + return ( + <SelectItem key={slug} value={slug}> + {provider?.name || slug} + </SelectItem> + ) + })} </SelectContent> </Select> <Select diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx new file mode 100644 index 000000000000..17bc213940e6 --- /dev/null +++ b/apps/desktop/src/app/settings/plugins-settings.tsx @@ -0,0 +1,124 @@ +import { useStore } from '@nanostores/react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Switch } from '@/components/ui/switch' +import { Tip } from '@/components/ui/tooltip' +import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store' +import { discoverRuntimePlugins } from '@/contrib/runtime-loader' +import { getStatus } from '@/hermes' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { Package } from '@/lib/icons' +import { notifyError } from '@/store/notifications' + +import { EmptyState, ListRow, Pill, SectionHeading, SettingsContent } from './primitives' + +const KIND_ORDER: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 } + +function reveal(file: string) { + void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined) +} + +async function revealPluginsDir() { + try { + const { hermes_home } = await getStatus() + // openDir (not reveal): the door often doesn't exist on first use, and + // showItemInFolder on a missing path silently no-ops (esp. Windows). + const result = await window.hermesDesktop?.openDir?.(`${hermes_home}/desktop-plugins`) + + if (result && !result.ok) { + notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder') + } + } catch (err) { + notifyError(err, 'Could not resolve the plugins folder') + } +} + +function PluginRow({ record }: { record: PluginRecord }) { + const { t } = useI18n() + const p = t.settings.plugins + + return ( + <ListRow + action={ + <div className="flex items-center justify-end gap-2"> + {record.file && ( + <Tip label={p.reveal}> + <Button onClick={() => reveal(record.file!)} size="icon" variant="ghost"> + <Codicon name="folder-opened" size="0.85rem" /> + </Button> + </Tip> + )} + <Switch + aria-label={`${record.status === 'disabled' ? p.enable : p.disable} ${record.name}`} + checked={record.status !== 'disabled'} + onCheckedChange={on => { + triggerHaptic('selection') + void setPluginEnabled(record.id, on) + }} + /> + </div> + } + description={ + record.status === 'error' ? ( + <span className="text-(--ui-danger,#f87171)">{record.error}</span> + ) : ( + record.file ?? record.id + ) + } + title={ + <span className="flex items-center gap-2"> + {record.name} + <Pill>{p.kinds[record.kind]}</Pill> + {record.status === 'error' && <Pill tone="primary">{p.failed}</Pill>} + </span> + } + /> + ) +} + +export function PluginsSettings() { + const { t } = useI18n() + const p = t.settings.plugins + const records = useStore($pluginRecords) + + const rows = Object.values(records).sort( + (a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name) + ) + + return ( + <SettingsContent> + <SectionHeading icon={Package} meta={p.count(rows.length)} title={p.title} /> + <p className="mb-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{p.blurb}</p> + + <div className="mb-4 flex items-center gap-2"> + <Button onClick={() => void revealPluginsDir()} size="sm" variant="outline"> + <Codicon name="folder-opened" size="0.8rem" /> + {p.openFolder} + </Button> + <Button + onClick={() => { + triggerHaptic('selection') + void discoverRuntimePlugins() + }} + size="sm" + variant="outline" + > + <Codicon name="refresh" size="0.8rem" /> + {p.rescan} + </Button> + </div> + + {rows.length === 0 ? ( + <EmptyState title={p.empty} /> + ) : ( + <div className="divide-y divide-(--ui-stroke-tertiary)"> + {rows.map(record => ( + <PluginRow key={record.id} record={record} /> + ))} + </div> + )} + </SettingsContent> + ) +} diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index beeddf32c386..27fefa5d484b 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -8,10 +8,14 @@ import { cn } from '@/lib/utils' import { PAGE_INSET_X } from '../layout-constants' -export function SettingsContent({ children }: { children: ReactNode }) { +// `bare` drops the page gutters + tall bottom pad for embedding in a tighter +// surface (e.g. the boot-failure recovery card owns its own padding). +export function SettingsContent({ children, bare = false }: { children: ReactNode; bare?: boolean }) { return ( <section className="min-h-0 overflow-hidden"> - <div className={cn('h-full min-h-0 overflow-y-auto pb-20', PAGE_INSET_X)}>{children}</div> + <div className={cn('h-full min-h-0 overflow-y-auto', bare ? 'px-5 pb-6' : cn('pb-20', PAGE_INSET_X))}> + {children} + </div> </section> ) } diff --git a/apps/desktop/src/app/settings/providers-settings.test.tsx b/apps/desktop/src/app/settings/providers-settings.test.tsx index 8a894e27ab15..ac63be962ae2 100644 --- a/apps/desktop/src/app/settings/providers-settings.test.tsx +++ b/apps/desktop/src/app/settings/providers-settings.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { atom } from 'nanostores' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -73,8 +73,12 @@ afterEach(() => { async function renderProvidersSettings() { const { ProvidersSettings } = await import('./providers-settings') + let result: ReturnType<typeof render> + await act(async () => { + result = render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />) + }) - return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />) + return result! } describe('ProvidersSettings', () => { @@ -82,7 +86,9 @@ describe('ProvidersSettings', () => { await renderProvidersSettings() const remove = await screen.findByRole('button', { name: 'Remove Nous Portal' }) - fireEvent.click(remove) + await act(async () => { + fireEvent.click(remove) + }) await waitFor(() => expect(disconnectOAuthProvider).toHaveBeenCalledWith('nous')) expect(listOAuthProviders).toHaveBeenCalledTimes(2) @@ -91,7 +97,9 @@ describe('ProvidersSettings', () => { it('keeps provider selection separate from account removal', async () => { await renderProvidersSettings() - fireEvent.click(await screen.findByText('Nous Portal')) + await act(async () => { + fireEvent.click(await screen.findByText('Nous Portal')) + }) expect(startManualProviderOAuth).toHaveBeenCalledWith('nous') expect(disconnectOAuthProvider).not.toHaveBeenCalled() @@ -132,7 +140,9 @@ describe('ProvidersSettings', () => { listOAuthProviders.mockResolvedValue({ providers: [] }) const { ProvidersSettings } = await import('./providers-settings') - render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />) + await act(async () => { + render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />) + }) expect(await screen.findByText('WidgetAI')).toBeTruthy() }) @@ -158,14 +168,18 @@ describe('ProvidersSettings', () => { // Typing narrows the list to matching providers only. const search = screen.getByPlaceholderText('Search providers…') - fireEvent.change(search, { target: { value: 'mid' } }) + await act(async () => { + fireEvent.change(search, { target: { value: 'mid' } }) + }) await waitFor(() => expect(screen.queryByText('Acme')).toBeNull()) expect(screen.getByText('Middle')).toBeTruthy() expect(screen.queryByText('Zebra')).toBeNull() // A non-matching query shows the empty-state copy. - fireEvent.change(search, { target: { value: 'nonesuch-xyz' } }) + await act(async () => { + fireEvent.change(search, { target: { value: 'nonesuch-xyz' } }) + }) expect(await screen.findByText('No providers match your search.')).toBeTruthy() }) }) diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index 1b6509ef1a72..f3637c37a001 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -9,6 +9,7 @@ export type SettingsView = | 'gateway' | 'keys' | 'notifications' + | 'plugins' | 'providers' | 'sessions' | `config:${string}` diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx deleted file mode 100644 index efe903596ea3..000000000000 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { useStore } from '@nanostores/react' -import type { CSSProperties, ReactNode } from 'react' -import { useSyncExternalStore } from 'react' - -import { NotificationStack } from '@/components/notifications' -import { PaneShell } from '@/components/pane-shell' -import { FloatingPet } from '@/components/pet/floating-pet' -import { SidebarProvider } from '@/components/ui/sidebar' -import { useMediaQuery } from '@/hooks/use-media-query' -import { - $fileBrowserOpen, - $panesFlipped, - $sidebarOpen, - FILE_BROWSER_DEFAULT_WIDTH, - FILE_BROWSER_PANE_ID, - setSidebarOpen -} from '@/store/layout' -import { $paneWidthOverride } from '@/store/panes' -import { $connection } from '@/store/session' -import { isSecondaryWindow } from '@/store/windows' - -import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants' - -import { useWindowControlsOverlayWidth } from './hooks/use-window-controls-overlay-width' -import { KeybindPanel } from './keybind-panel' -import { StatusbarControls, type StatusbarItem } from './statusbar-controls' -import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar' -import { TitlebarControls, type TitlebarTool } from './titlebar-controls' - -interface AppShellProps { - children: ReactNode - leftStatusbarItems?: readonly StatusbarItem[] - leftTitlebarTools?: readonly TitlebarTool[] - // Fixed-position overlays that must share <main>'s stacking context so pane - // resize handles (z-20) paint above them. The persistent terminal lives here: - // hoisting it to the root `overlays` layer (sibling of <main>, z above z-3) - // would cover every pane's drag handle. - mainOverlays?: ReactNode - onOpenSettings: () => void - overlays?: ReactNode - // Rails that sit at the window's left edge in the flipped layout but never - // force-collapse to hover-reveal overlays — so they cover the top-left traffic - // lights (and zero the titlebar inset) even below the collapse breakpoint. - previewPaneOpen?: boolean - statusbarItems?: readonly StatusbarItem[] - terminalPaneOpen?: boolean - titlebarTools?: readonly TitlebarTool[] -} - -// Renderer-side fallback so layout snaps even when the main-process fullscreen event -// hasn't landed yet (e.g. dev reloads, before the IPC bridge is wired). -function subscribeWindowSize(cb: () => void) { - window.addEventListener('resize', cb) - window.addEventListener('fullscreenchange', cb) - - return () => { - window.removeEventListener('resize', cb) - window.removeEventListener('fullscreenchange', cb) - } -} - -const viewportIsFullscreen = () => - window.innerWidth >= window.screen.width && window.innerHeight >= window.screen.height - -export function AppShell({ - children, - leftStatusbarItems, - leftTitlebarTools, - mainOverlays, - onOpenSettings, - overlays, - previewPaneOpen = false, - statusbarItems, - terminalPaneOpen = false, - titlebarTools -}: AppShellProps) { - const sidebarOpen = useStore($sidebarOpen) - const fileBrowserOpen = useStore($fileBrowserOpen) - const panesFlipped = useStore($panesFlipped) - const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY) - const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID)) - const connection = useStore($connection) - const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) - const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen - // Every secondary window (new-session scratch, subagent watch, cmd-click - // pop-out) is a compact side panel — none of them carry the full titlebar - // tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag. - const hideTitlebarControls = isSecondaryWindow() - const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen) - // Width Windows/WSLg reserve for the native min/max/close overlay (zero on - // macOS, where window controls sit on the left and are reported via - // windowButtonPosition instead). The right tool cluster has to clear them. - // Prefer the EXACT width measured from the live Window Controls Overlay - // (precise + self-correcting across DPI/host themes); fall back to the static - // reservation the main process sends when the WCO API isn't available. - const measuredOverlayWidth = useWindowControlsOverlayWidth() - const staticOverlayWidth = connection?.nativeOverlayWidth ?? 0 - const nativeOverlayWidth = measuredOverlayWidth ?? staticOverlayWidth - const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem' - - // When the native window controls overlay our titlebar band — Windows and - // WSLg both paint Electron's Window Controls Overlay and report - // nativeOverlayWidth > 0 — the right rail's editor-style tab strip (which - // normally lives IN that band) would render at y=0 under the fixed titlebar - // tool cluster and collide with it. Drop the right rail one titlebar-height so - // it opens BELOW the band. macOS / plain Linux paint no overlay → 0 inset, - // layout byte-for-byte unchanged. Consumed as --right-rail-top-inset. - const rightRailTopInset = nativeOverlayWidth > 0 ? 'var(--titlebar-height)' : '0px' - - // The inset clears the top-left titlebar buttons when nothing covers the - // window's left edge. Default layout: the sessions sidebar sits there. - // Flipped layout: the file browser does instead. Both force-collapse to a - // hover-reveal overlay (0px track) below the collapse breakpoint, so the edge - // is uncovered there regardless of their stored open state. A standalone - // session window renders no sidebar at all, so its edge is always uncovered. - const collapsibleLeftPaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen - // The terminal + preview rails never force-collapse, so when they're the - // leftmost open pane (flipped layout) they cover the edge even when narrow. - const persistentLeftPaneOpen = panesFlipped && (terminalPaneOpen || previewPaneOpen) - - const leftEdgePaneOpen = - !isSecondaryWindow() && ((!narrowViewport && collapsibleLeftPaneOpen) || persistentLeftPaneOpen) - - const titlebarContentInset = leftEdgePaneOpen - ? 0 - : titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2) - - // The static system cluster (haptics, profiles, settings, right-sidebar) is - // hardcoded in TitlebarControls. Pane-supplied tools (preview's group) render - // in a separate cluster anchored further left. - // - // Width math has to include the `gap-x-1` (0.25rem) between buttons: - // N buttons + (N - 1) inner gaps, plus one extra 0.25rem of breathing room - // between the pane-tool cluster and the system cluster so they don't sit - // flush against each other. Modeled as N gaps (N - 1 inner + 1 trailing) - // to keep the formula generic for any pane-tool count. - const SYSTEM_TOOL_COUNT = 4 - const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0 - const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))` - - const fileBrowserWidth = - fileBrowserWidthOverride !== undefined ? `${fileBrowserWidthOverride}px` : FILE_BROWSER_DEFAULT_WIDTH - - // Where the pane-tool cluster's right edge sits, measured from the inner - // titlebar padding (--titlebar-tools-right). Two anchors: - // - file-browser closed → flush against static cluster's left edge - // - file-browser open → flush against the file-browser pane's left edge - // (= preview pane's right edge) - const previewToolbarGap = fileBrowserOpen ? fileBrowserWidth : systemToolsWidth - - // Used by the drag region to know where the rightmost interactive element - // ends. When pane tools are present, that's `gap + paneCount * controlSize - // + paneCount * 0.25rem` (the leftmost button is at `tools-right + gap + - // paneCount * (size + gap-x-1)`). Otherwise the static cluster's footprint - // is enough. - const titlebarToolsWidth = - paneToolCount > 0 - ? `calc(${previewToolbarGap} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))` - : systemToolsWidth - - return ( - <SidebarProvider - className="h-screen min-h-0 flex-col bg-background" - onOpenChange={setSidebarOpen} - open={sidebarOpen} - style={ - { - // Alias for shadcn <Sidebar> descendants. Resolves to the chat-sidebar - // pane track via PaneShell's emitted --pane-chat-sidebar-width. - '--sidebar-width': 'var(--pane-chat-sidebar-width)', - '--titlebar-height': `${TITLEBAR_HEIGHT}px`, - '--titlebar-content-inset': `${titlebarContentInset}px`, - '--titlebar-controls-left': `${titlebarControls.left}px`, - '--titlebar-controls-top': `${titlebarControls.top}px`, - '--titlebar-tools-right': titlebarToolsRight, - '--titlebar-tools-width': titlebarToolsWidth, - // Drops the right rail below the titlebar band when the OS/host paints - // window controls over it (Windows/WSLg); 0px elsewhere. - '--right-rail-top-inset': rightRailTopInset, - // Anchor for the pane-tool cluster's right edge in TitlebarControls. - // Sourced from the layout store rather than the PaneShell-emitted - // --pane-*-width vars because the titlebar is a sibling of PaneShell - // and CSS variables resolve at the consumer's scope. - '--shell-preview-toolbar-gap': previewToolbarGap - } as CSSProperties - } - > - {!hideTitlebarControls && ( - <TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} /> - )} - - {nativeOverlayWidth > 0 && ( - <div - aria-hidden - className="pointer-events-none fixed right-0 top-0 z-[4] h-(--titlebar-height) w-(--titlebar-tools-right) border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background)" - /> - )} - - <main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none"> - <PaneShell className="min-h-0 flex-1"> - <div - aria-hidden="true" - className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]" - /> - <div - aria-hidden="true" - className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]" - /> - - {children} - </PaneShell> - - {/* Fixed overlays scoped to main's stacking context (terminal). Rendered - after PaneShell so it paints over pane content, but its z stays under - the panes' z-20 resize handles, keeping every pane resizable. */} - {mainOverlays} - - {/* The compact pop-out drops the statusbar — it's a scratch window, not - the full shell. */} - {!isSecondaryWindow() && <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />} - </main> - - {overlays} - - {/* Keybind map dialog (titlebar ⌨ button / ⌘/). */} - <KeybindPanel /> - - {/* Mounted at the shell root (after overlays) so success/error toasts - surface above every route and overlay — not just the chat view. */} - <NotificationStack /> - - {/* Petdex floating mascot — in-window, always-on-top, reactive to agent - activity. Renders nothing unless a pet is installed + enabled. */} - <FloatingPet /> - </SidebarProvider> - ) -} diff --git a/apps/desktop/src/app/shell/approval-mode-menu.test.tsx b/apps/desktop/src/app/shell/approval-mode-menu.test.tsx new file mode 100644 index 000000000000..05a122f8bf65 --- /dev/null +++ b/apps/desktop/src/app/shell/approval-mode-menu.test.tsx @@ -0,0 +1,89 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { StatusbarControls } from '@/app/shell/statusbar-controls' +import { I18nProvider } from '@/i18n' +import { $approvalModes } from '@/store/approval-mode' + +import { useApprovalModeStatusbarItem } from './approval-mode-menu' + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +beforeAll(() => { + vi.stubGlobal('ResizeObserver', TestResizeObserver) + Element.prototype.hasPointerCapture ??= () => false + Element.prototype.setPointerCapture ??= () => undefined + Element.prototype.releasePointerCapture ??= () => undefined + HTMLElement.prototype.scrollIntoView ??= () => undefined +}) + +afterEach(() => { + cleanup() + $approvalModes.set({}) +}) + +function Harness({ + profile = 'default', + requestGateway +}: { + profile?: string + requestGateway: (method: string, params?: Record<string, unknown>) => Promise<unknown> +}) { + const item = useApprovalModeStatusbarItem(profile, requestGateway) + + return ( + <MemoryRouter> + <StatusbarControls items={[item]} /> + </MemoryRouter> + ) +} + +describe('approval mode statusbar item', () => { + it('uses the shared statusbar menu trigger without a nested bespoke button', async () => { + const response = new Promise<never>(() => undefined) + render(<Harness requestGateway={vi.fn(() => response)} />) + + const statusbar = screen.getByRole('contentinfo') + const trigger = within(statusbar).getByRole('button', { name: /smart/i }) + expect(within(statusbar).getAllByRole('button')).toHaveLength(1) + + fireEvent.pointerDown(trigger, { button: 0 }) + + expect(await screen.findByRole('menuitemradio', { name: /manual/i })).toBeTruthy() + expect(trigger.getAttribute('aria-haspopup')).toBe('menu') + expect(screen.getByRole('menuitemradio', { name: /smart/i })).toBeTruthy() + expect(screen.getByRole('menuitemradio', { name: /off/i })).toBeTruthy() + }) + + it('writes the selected mode through the gateway and updates its shared trigger label', async () => { + const requestGateway = vi.fn(async (_method, params) => ({ value: params?.value ?? 'smart' })) + render(<Harness profile="work" requestGateway={requestGateway} />) + + fireEvent.pointerDown(screen.getByRole('button', { name: /smart/i }), { button: 0 }) + fireEvent.click(await screen.findByRole('menuitemradio', { name: /manual/i })) + + await waitFor(() => { + expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'approvals.mode', value: 'manual' }) + expect(screen.getByRole('button', { name: /manual/i })).toBeTruthy() + }) + }) + + it('renders the shared trigger and menu in the active locale', async () => { + const response = new Promise<never>(() => undefined) + render( + <I18nProvider configClient={null} initialLocale="ja"> + <Harness requestGateway={vi.fn(() => response)} /> + </I18nProvider> + ) + + fireEvent.pointerDown(screen.getByRole('button', { name: 'スマート' }), { button: 0 }) + + expect(await screen.findByText('必要な場合にのみ確認します')).toBeTruthy() + expect(screen.getByText('承認プロンプトなしで実行します')).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/app/shell/approval-mode-menu.tsx b/apps/desktop/src/app/shell/approval-mode-menu.tsx new file mode 100644 index 000000000000..40f9f236a180 --- /dev/null +++ b/apps/desktop/src/app/shell/approval-mode-menu.tsx @@ -0,0 +1,81 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useMemo } from 'react' + +import type { StatusbarItem } from '@/app/shell/statusbar-controls' +import { + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator +} from '@/components/ui/dropdown-menu' +import { useI18n } from '@/i18n' +import { Zap, ZapFilled } from '@/lib/icons' +import { + $approvalModes, + type ApprovalMode, + type ApprovalModeRequester, + setApprovalModeForProfile, + syncApprovalModeForProfile +} from '@/store/approval-mode' + +export function useApprovalModeStatusbarItem( + profile: string, + requestGateway: ApprovalModeRequester +): StatusbarItem { + const { t } = useI18n() + const copy = t.shell.approvalMode + const modes = useStore($approvalModes) + const mode = modes[profile.trim() || 'default'] ?? 'smart' + + const labels = useMemo<Record<ApprovalMode, string>>( + () => ({ manual: copy.manual, smart: copy.smart, off: copy.off }), + [copy.manual, copy.off, copy.smart] + ) + + const descriptions = useMemo<Record<ApprovalMode, string>>( + () => ({ + manual: copy.manualDescription, + smart: copy.smartDescription, + off: copy.offDescription + }), + [copy.manualDescription, copy.offDescription, copy.smartDescription] + ) + + useEffect(() => { + void syncApprovalModeForProfile(requestGateway, profile).catch(() => undefined) + }, [profile, requestGateway]) + + return { + className: mode === 'off' ? 'bg-(--chrome-action-hover) text-foreground' : undefined, + icon: mode === 'off' ? <ZapFilled className="size-3.5" /> : <Zap className="size-3.5 opacity-70" />, + id: 'approval-mode', + label: labels[mode], + menuAlign: 'end', + menuClassName: 'w-72 p-1', + menuContent: ( + <> + <DropdownMenuLabel>{copy.title}</DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuRadioGroup + onValueChange={value => { + void setApprovalModeForProfile(requestGateway, profile, value as ApprovalMode).catch(() => undefined) + }} + value={mode} + > + {(['manual', 'smart', 'off'] as const).map(value => ( + <DropdownMenuRadioItem className="items-start gap-2" key={value} value={value}> + <span className="flex min-w-0 flex-col gap-0.5"> + <span className="text-xs text-foreground">{labels[value]}</span> + <span className="text-[0.6875rem] leading-snug text-(--ui-text-tertiary)"> + {descriptions[value]} + </span> + </span> + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </> + ), + title: copy.ariaLabel(labels[mode]), + variant: 'menu' + } +} diff --git a/apps/desktop/src/app/shell/gateway-menu-panel.tsx b/apps/desktop/src/app/shell/gateway-menu-panel.tsx index c5e542f36fa8..bb753670bb60 100644 --- a/apps/desktop/src/app/shell/gateway-menu-panel.tsx +++ b/apps/desktop/src/app/shell/gateway-menu-panel.tsx @@ -36,21 +36,27 @@ function useGatewayLogTail(): string[] { useEffect(() => { let cancelled = false - const load = () => - getLogs({ file: 'gui', lines: LOG_TAIL }) - .then(res => { - if (cancelled) { - return - } + // async: getLogs THROWS (not rejects) when the desktop bridge is missing + // (plain-browser mode) — a sync throw here would take down the root + // error boundary before the .catch even attaches. + const load = async () => { + try { + const res = await getLogs({ file: 'gui', lines: LOG_TAIL }) - setLines( - res.lines - .map(line => line.trim()) - .filter(line => line && !LOG_NOISE_RE.test(line)) - .slice(-LOG_VISIBLE) - ) - }) - .catch(() => {}) + if (cancelled) { + return + } + + setLines( + res.lines + .map(line => line.trim()) + .filter(line => line && !LOG_NOISE_RE.test(line)) + .slice(-LOG_VISIBLE) + ) + } catch { + // Bridge/gateway unavailable — keep the last tail. + } + } void load() const timer = window.setInterval(load, LOG_POLL_MS) diff --git a/apps/desktop/src/app/shell/group-setter.ts b/apps/desktop/src/app/shell/group-setter.ts new file mode 100644 index 000000000000..b0f212f15e30 --- /dev/null +++ b/apps/desktop/src/app/shell/group-setter.ts @@ -0,0 +1,6 @@ +// The `GroupSetter` shape pages take as an extension-point prop (SkillsView, +// MessagingView, ChatPreviewRail, …). The live implementation is the +// registry-backed `registryGroupSetter` in app/contrib/panes.tsx. +type Side = 'left' | 'right' + +export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 753c3893bbd5..b4aae4295b05 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -1,28 +1,34 @@ import { useStore } from '@nanostores/react' -import { useCallback, useMemo } from 'react' +import { useMemo } from 'react' import type { CommandCenterSection } from '@/app/command-center' import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store' +import { useApprovalModeStatusbarItem } from '@/app/shell/approval-mode-menu' import { ContextUsagePanel } from '@/app/shell/context-usage-panel' import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' -import { Activity, AlertCircle, Clock, Command, Hash, Loader2, Terminal, Zap, ZapFilled } from '@/lib/icons' +import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal } from '@/lib/icons' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { cn } from '@/lib/utils' -import { setGlobalYolo, setSessionYolo } from '@/lib/yolo-session' +import { copyFilePath, revealFile } from '@/store/file-actions' +import { revealFileInTree } from '@/store/layout' +import { $activeGatewayProfile } from '@/store/profile' import { $activeSessionId, $busy, $connection, + $currentCwd, $currentUsage, + $selectedStoredSessionId, + $sessions, $sessionStartedAt, $turnStartedAt, - $yoloActive, - setYoloActive + sessionMatchesStoredId } from '@/store/session' +import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states' import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents' import { $gatewayRestarting } from '@/store/system-actions' import { @@ -36,7 +42,16 @@ import { import type { StatusResponse } from '@/types/hermes' import { CRON_ROUTE } from '../../routes' -import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-controls' +import type { StatusbarItem } from '../statusbar-controls' + +const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const + +function workspaceLabel(cwd: string): string { + const normalized = cwd.replace(/[\\/]+$/, '') + const leaf = normalized.split(/[\\/]/).filter(Boolean).pop() + + return leaf || cwd +} interface StatusbarItemsOptions { agentsOpen: boolean @@ -64,21 +79,22 @@ export function useStatusbarItems({ inferenceStatus, openAgents, openCommandCenterSection, - freshDraftReady, requestGateway, statusSnapshot, toggleCommandCenter }: StatusbarItemsOptions) { const { t } = useI18n() const copy = t.shell.statusbar - const activeSessionId = useStore($activeSessionId) + const fileMenu = t.fileMenu + const primaryActiveSessionId = useStore($activeSessionId) + const activeGatewayProfile = useStore($activeGatewayProfile) const terminalTakeover = useStore($terminalTakeover) - const yoloActive = useStore($yoloActive) - const busy = useStore($busy) - const currentUsage = useStore($currentUsage) + const primaryBusy = useStore($busy) + const currentCwd = useStore($currentCwd) + const primaryUsage = useStore($currentUsage) const gatewayRestarting = useStore($gatewayRestarting) - const sessionStartedAt = useStore($sessionStartedAt) - const turnStartedAt = useStore($turnStartedAt) + const primarySessionStartedAt = useStore($sessionStartedAt) + const primaryTurnStartedAt = useStore($turnStartedAt) const subagentsBySession = useStore($subagentsBySession) const updateStatus = useStore($updateStatus) const updateApply = useStore($updateApply) @@ -87,47 +103,41 @@ export function useStatusbarItems({ const desktopVersion = useStore($desktopVersion) const connection = useStore($connection) + // The FOCUSED session (interacted tile, else the primary — the same + // derivation the titlebar title follows): every session-scoped readout + // below (context count, timers, busy pulse) tracks it, so clicking into a + // tile makes the statusbar describe THAT session. + const focusedStoredSessionId = useStore($focusedStoredSessionId) + const focusedRuntimeId = useStore($focusedRuntimeId) + const focusedState = useStore($focusedSessionState) + const sessions = useStore($sessions) + const selectedStoredSessionId = useStore($selectedStoredSessionId) + const primaryFocused = !focusedStoredSessionId || focusedStoredSessionId === selectedStoredSessionId + + const activeSessionId = primaryFocused ? primaryActiveSessionId : (focusedRuntimeId ?? null) + const busy = primaryFocused ? primaryBusy : Boolean(focusedState?.busy) + + // EMPTY_USAGE (module constant) keeps the fallback referentially stable — + // a fresh `{...}` each render would bust the usage-label memos below. + const currentUsage = primaryFocused ? primaryUsage : (focusedState?.usage ?? EMPTY_USAGE) + + const turnStartedAt = primaryFocused ? primaryTurnStartedAt : (focusedState?.turnStartedAt ?? null) + + // A tile's session-start comes from its stored row (the cache only knows + // runtime state); seconds → ms. + const focusedRow = focusedStoredSessionId + ? sessions.find(s => sessionMatchesStoredId(s, focusedStoredSessionId)) + : null + + const sessionStartedAt = primaryFocused + ? primarySessionStartedAt + : focusedRow?.started_at + ? focusedRow.started_at * 1000 + : null + const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage]) const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage]) - - // Per-session approval bypass (same scope as the TUI's Shift+Tab). On a - // new-chat draft (no runtime session yet) we arm locally; the session-create - // path applies it once the backend session exists. - // - // Shift+click flips the GLOBAL approvals.mode instead — a persistent, - // all-sessions/CLI/TUI/cron bypass that survives restarts. - const toggleYolo = useCallback( - async (modifiers?: StatusbarSelectModifiers) => { - const next = !$yoloActive.get() - - setYoloActive(next) - - if (modifiers?.shiftKey) { - try { - await setGlobalYolo(requestGateway, next) - } catch { - setYoloActive(!next) - } - - return - } - - const sid = $activeSessionId.get() - - if (!sid) { - return - } - - try { - await setSessionYolo(requestGateway, sid, next) - } catch { - setYoloActive(!next) - } - }, - [requestGateway] - ) - - const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady) + const approvalModeItem = useApprovalModeStatusbarItem(activeGatewayProfile, requestGateway) const gatewayMenuContent = useMemo( () => (close: () => void) => ( @@ -299,6 +309,36 @@ export function useStatusbarItems({ title: inferenceStatus?.reason || copy.gatewayTitle, variant: 'menu' }, + { + hidden: !currentCwd, + icon: <FolderOpen className="size-3" />, + id: 'workspace-cwd', + label: currentCwd ? workspaceLabel(currentCwd) : undefined, + menuItems: currentCwd + ? [ + { + id: 'copy-workspace-path', + label: fileMenu.copyPath, + onSelect: () => void copyFilePath(currentCwd), + title: currentCwd + }, + { + id: 'reveal-workspace-finder', + label: fileMenu.revealFileManager, + onSelect: () => void revealFile(currentCwd), + title: currentCwd + }, + { + id: 'reveal-workspace-sidebar', + label: fileMenu.revealInSidebar, + onSelect: () => revealFileInTree(currentCwd), + title: currentCwd + } + ] + : undefined, + title: currentCwd || undefined, + variant: 'menu' + }, { className: cn( agentsOpen && 'bg-accent/55 text-foreground', @@ -337,6 +377,10 @@ export function useStatusbarItems({ agentsOpen, commandCenterOpen, copy, + currentCwd, + fileMenu.copyPath, + fileMenu.revealFileManager, + fileMenu.revealInSidebar, gatewayMenuContent, gatewayClassName, gatewayDetail, @@ -383,17 +427,8 @@ export function useStatusbarItems({ variant: 'text' }, { - className: cn('px-1', yoloActive && 'bg-(--chrome-action-hover)'), - hidden: !showYoloToggle, - icon: yoloActive ? ( - <ZapFilled className="size-3.5 shrink-0" /> - ) : ( - <Zap className="size-3.5 shrink-0 opacity-70" /> - ), - id: 'yolo', - onSelect: modifiers => void toggleYolo(modifiers), - title: yoloActive ? copy.yoloOn : copy.yoloOff, - variant: 'action' + ...approvalModeItem, + hidden: gatewayState !== 'open', }, { className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`, @@ -409,6 +444,7 @@ export function useStatusbarItems({ ], [ activeSessionId, + approvalModeItem, backendVersionItem, busy, chatOpen, @@ -419,11 +455,9 @@ export function useStatusbarItems({ currentUsage, requestGateway, sessionStartedAt, - showYoloToggle, + gatewayState, terminalTakeover, - toggleYolo, turnStartedAt, - yoloActive ] ) diff --git a/apps/desktop/src/app/shell/keybind-panel.tsx b/apps/desktop/src/app/shell/keybind-panel.tsx index ff0b7b27ff1a..7cbfca1a6035 100644 --- a/apps/desktop/src/app/shell/keybind-panel.tsx +++ b/apps/desktop/src/app/shell/keybind-panel.tsx @@ -6,14 +6,16 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { Kbd, KbdCombo } from '@/components/ui/kbd' +import { useContributions } from '@/contrib/react/use-contributions' import { useI18n } from '@/i18n' import { - KEYBIND_ACTIONS, + allKeybindActions, KEYBIND_CATEGORIES, KEYBIND_PANEL_ACTION, KEYBIND_READONLY, type KeybindActionMeta, - type KeybindReadonly + type KeybindReadonly, + KEYBINDS_AREA } from '@/lib/keybinds/actions' import { formatCombo } from '@/lib/keybinds/combo' import { arraysEqual } from '@/lib/storage' @@ -22,6 +24,7 @@ import { $capture, $keybindPanelOpen, beginCapture, + bindingsFor, closeKeybindPanel, conflictsFor, endCapture, @@ -36,6 +39,9 @@ export function KeybindPanel() { const bindings = useStore($bindings) const k = t.keybinds const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set()) + // Subscribe so contributed actions appear/disappear live in the map. + useContributions(KEYBINDS_AREA) + const actionList = allKeybindActions() const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0] @@ -74,7 +80,7 @@ export function KeybindPanel() { {/* Body */} <div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5"> {KEYBIND_CATEGORIES.map(category => { - const actions = KEYBIND_ACTIONS.filter( + const actions = actionList.filter( action => action.category === category && action.id !== KEYBIND_PANEL_ACTION ) @@ -139,9 +145,12 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) { const bindings = useStore($bindings) const capture = useStore($capture) - const combos = bindings[action.id] ?? [] + // bindingsFor resolves stored overrides for late-registered (contributed) + // actions too — $bindings only carries built-ins, so a raw lookup would show + // the default instead of the user's rebinding for a plugin/contrib action. + const combos = bindingsFor(action.id, bindings) const capturing = capture === action.id - const label = k.actions[action.id] ?? action.id + const label = k.actions[action.id] ?? action.label ?? action.id const isDefault = arraysEqual(combos, [...action.defaults]) const conflict = combos diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index cf2a8af660ff..dda409699f55 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -24,7 +24,9 @@ const EFFORT_OPTIONS = [ { value: 'low', labelKey: 'low' }, { value: 'medium', labelKey: 'medium' }, { value: 'high', labelKey: 'high' }, - { value: 'xhigh', labelKey: 'max' } + { value: 'xhigh', labelKey: 'xhigh' }, + { value: 'max', labelKey: 'max' }, + { value: 'ultra', labelKey: 'ultra' } ] as const /** How "fast" is achieved for a given model — two different mechanisms: diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx index 57125de35da3..7ca78274f343 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { cleanup, findByText, fireEvent, render } from '@testing-library/react' +import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { DropdownMenu, DropdownMenuContent } from '@/components/ui/dropdown-menu' @@ -39,7 +39,8 @@ afterEach(() => { function renderPanel(onSelectModel = vi.fn()) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - render( + + const content = render( <QueryClientProvider client={client}> <DropdownMenu open> <DropdownMenuContent> @@ -49,50 +50,55 @@ function renderPanel(onSelectModel = vi.fn()) { </QueryClientProvider> ) - return onSelectModel + return { onSelectModel, content } } describe('ModelMenuPanel MoA presets', () => { it('selecting a MoA preset switches PERSISTENTLY via onSelectModel (not the one-shot dispatch)', async () => { - const onSelectModel = renderPanel() + const { content, onSelectModel } = renderPanel() // moaOptions is async (useQuery) — wait for the preset row to mount. - const row = await findByText(document.body, 'MoA: BeastMode') + const row = await content.findByText('MoA: BeastMode') fireEvent.click(row) // #54670: must route through the persistent model-switch path - // (config.set model="<preset> --provider moa"), i.e. onSelectModel with - // provider 'moa', NOT a one-shot command.dispatch that reverts after a turn. + // i.e. onSelectModel with provider 'moa' (which session-scopes live-session + // switches), NOT a one-shot command.dispatch that reverts after a turn. expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa' }) }) it('shows the check on the preset that matches the current moa selection', async () => { $currentProvider.set('moa') $currentModel.set('BeastMode') - renderPanel() + const { content } = renderPanel() - const row = await findByText(document.body, 'MoA: BeastMode') + const row = await content.findByText('MoA: BeastMode') // The check codicon renders as a sibling within the same row item. const item = row.closest('[role="menuitem"]') ?? row.parentElement expect(item?.querySelector('.codicon-check')).not.toBeNull() }) it('keeps the virtual moa provider out of the main model groups (presets section only)', async () => { - renderPanel() + const { content } = renderPanel() - await findByText(document.body, 'MoA: BeastMode') + await content.findByText('MoA: BeastMode') // The provider group header would read "Mixture of Agents"; the presets // section header reads "MoA presets". Only the latter should exist. + // Radix DropdownMenu portals its content to document.body, so assert + // against the body (not content.container) to see the rendered items. + + // eslint-disable-next-line no-restricted-globals expect(document.body.textContent).toContain('MoA presets') + // eslint-disable-next-line no-restricted-globals expect(document.body.textContent).not.toContain('Mixture of Agents') }) it('renders presets from the catalog even before a session exists', async () => { $activeSessionId.set('') - const onSelectModel = renderPanel() + const { onSelectModel, content } = renderPanel() - const row = await findByText(document.body, 'MoA: BeastMode') + const row = await content.findByText('MoA: BeastMode') fireEvent.click(row) // Pre-session picks are UI state shipped on the next session.create — the diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index f358a29ffabf..eec7385af8c9 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -180,8 +180,8 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model } // Selecting a MoA preset switches the session to it PERSISTENTLY, using the - // same path real provider selections use (config.set model="<preset> - // --provider moa" via onSelectModel → the gateway's persistent switch_model). + // same path real provider selections use (onSelectModel → config.set with + // --session for live sessions → the gateway's persistent switch_model). // Previously this dispatched the one-shot `/moa` command, which ran a single // turn through MoA and then silently reverted to the prior model (#54670) — // the dropdown presented presets like persistent selections but they weren't. diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index 9a9f0980884b..096af07a96c3 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -25,6 +25,10 @@ export interface StatusbarMenuItem { export interface StatusbarItem { id: string + /** Escape hatch: render an arbitrary node into the bar (own state, tooltip, + * events). When set, it OWNS the slot — label/variant/onSelect are ignored. + * This is how a plugin drops a full stateful React component into the bar. */ + render?: () => ReactNode label?: ReactNode detail?: ReactNode icon?: ReactNode @@ -92,6 +96,11 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) { const [menuOpen, setMenuOpen] = useState(false) + // Render escape hatch: the contribution owns its own chrome/state/tooltip. + if (item.render) { + return <>{item.render()}</> + } + const content = ( <> {item.icon} @@ -100,7 +109,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: </> ) - if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) { + if (item.variant === 'menu' && (item.menuContent || !!item.menuItems?.length)) { // The `Tip` helper can't wrap a menu: its TooltipTrigger needs a DOM child, // but DropdownMenu's Root renders no element, so the hover listeners never // land on the button and the tooltip silently never shows. Compose the two diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index fb0cae4307e1..15ca9c9cb688 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -1,7 +1,9 @@ import { useStore } from '@nanostores/react' -import type { ComponentProps, ReactNode } from 'react' +import { type ComponentProps, type MouseEvent, type ReactNode, useEffect, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' +import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode' +import { resetLayoutTree } from '@/components/pane-shell/tree/store' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' @@ -9,10 +11,8 @@ import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics' -import { toggleKeybindPanel } from '@/store/keybinds' import { $fileBrowserOpen, - $panesFlipped, $sidebarOpen, toggleFileBrowserOpen, togglePanesFlipped, @@ -32,7 +32,7 @@ export interface TitlebarTool { hidden?: boolean href?: string icon: ReactNode - onSelect?: () => void + onSelect?: (event?: MouseEvent) => void title?: string to?: string } @@ -46,14 +46,60 @@ interface TitlebarControlsProps extends ComponentProps<'div'> { onOpenSettings: () => void } +/** + * The layout button's glyph. Morphs into its composite reset form — the + * layout icon wearing a small counter-clockwise arrow badge ("layout, back + * to how it was") — ONLY while the pointer is on the button AND ⌘/Ctrl is + * held: hover gates via CSS (`group/tool` on the button), the modifier via + * the window listener. Pressing the modifier elsewhere changes nothing. + */ +function LayoutGlyph({ modHeld }: { modHeld: boolean }) { + return ( + <> + <span className={cn('inline-flex', modHeld && 'group-hover/tool:hidden')}> + <Codicon name="layout" /> + </span> + <span className={cn('relative hidden', modHeld && 'group-hover/tool:inline-flex')}> + <Codicon name="layout" /> + <span className="absolute -bottom-1 -right-1.5 grid place-items-center rounded-full bg-(--ui-bg-chrome) p-px"> + <Codicon className="-scale-x-100" name="refresh" size="0.5625rem" /> + </span> + </span> + </> + ) +} + +/** Live ⌘/Ctrl tracking — mod-click affordances telegraph themselves (the + * layout button morphs into its reset form while the modifier is down). */ +function useModifierHeld(): boolean { + const [held, setHeld] = useState(false) + + useEffect(() => { + const sync = (event: KeyboardEvent) => setHeld(event.metaKey || event.ctrlKey) + const clear = () => setHeld(false) + + window.addEventListener('keydown', sync) + window.addEventListener('keyup', sync) + window.addEventListener('blur', clear) + + return () => { + window.removeEventListener('keydown', sync) + window.removeEventListener('keyup', sync) + window.removeEventListener('blur', clear) + } + }, []) + + return held +} + export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) { const { t } = useI18n() const navigate = useNavigate() const location = useLocation() + const modHeld = useModifierHeld() const hapticsMuted = useStore($hapticsMuted) const fileBrowserOpen = useStore($fileBrowserOpen) const sidebarOpen = useStore($sidebarOpen) - const panesFlipped = useStore($panesFlipped) const toggleHaptics = () => { if (!hapticsMuted) { @@ -67,14 +113,13 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: } } - // Each titlebar button controls the pane physically on its side, so a flip - // swaps which pane each one toggles. Default: sessions left, file browser - // right. Flipped: file browser left, sessions right. Sidebar toggles never - // carry an active highlight — they're plain show/hide affordances. - const fileBrowserEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen } - const sessionsEdge = { open: sidebarOpen, toggle: toggleSidebarOpen } - const leftEdge = panesFlipped ? fileBrowserEdge : sessionsEdge - const rightEdge = panesFlipped ? sessionsEdge : fileBrowserEdge + // POSITIONAL toggles: each button shows/hides everything on its physical + // side of the main zone (the layout tree collapses the whole side), so they + // stay correct through flips and rearranges. $sidebarOpen ≙ left side, + // $fileBrowserOpen ≙ right side. Never an active highlight — plain + // show/hide affordances. + const leftEdge = { open: sidebarOpen, toggle: toggleSidebarOpen } + const rightEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen } const leftToolbarTools: TitlebarTool[] = [ { @@ -111,6 +156,26 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: // Static system tools — always pinned to the screen's right edge. const systemTools: TitlebarTool[] = [ + { + className: 'group/tool', + // Hover + held ⌘/Ctrl morphs the glyph into its reset form (see + // LayoutGlyph) — the mod-click telegraphs itself before it happens. + icon: <LayoutGlyph modHeld={modHeld} />, + id: 'layout', + label: t.titlebar.layoutEditor, + onSelect: event => { + if (event?.metaKey || event?.ctrlKey) { + triggerHaptic('warning') + resetLayoutTree() + + return + } + + triggerHaptic('open') + toggleLayoutEditMode() + }, + title: t.titlebar.layoutEditorTitle + }, { active: hapticsMuted, icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />, @@ -118,15 +183,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics, onSelect: toggleHaptics }, - { - icon: <Codicon name="keyboard" />, - id: 'keybinds', - label: t.titlebar.openKeybinds, - onSelect: () => { - triggerHaptic('open') - toggleKeybindPanel() - } - }, { icon: <Codicon name="settings-gear" />, id: 'settings', @@ -147,8 +203,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: } const visibleSystemTools = systemTools.filter(tool => !tool.hidden) - const settingsTool = visibleSystemTools.find(tool => tool.id === 'settings') - const visibleSystemToolsBeforeSettings = visibleSystemTools.filter(tool => tool.id !== 'settings') const visiblePaneTools = tools.filter(tool => !tool.hidden) return ( @@ -187,10 +241,9 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: aria-label={t.shell.appControls} className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]" > - {visibleSystemToolsBeforeSettings.map(tool => ( + {visibleSystemTools.map(tool => ( <TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} /> ))} - {settingsTool && <TitlebarToolButton navigate={navigate} tool={settingsTool} />} <TitlebarToolButton navigate={navigate} tool={rightSidebarTool} /> </div> </> @@ -228,12 +281,12 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us aria-pressed={tool.active ?? undefined} className={className} disabled={tool.disabled} - onClick={() => { + onClick={event => { if (tool.to) { navigate(tool.to) } - tool.onSelect?.() + tool.onSelect?.(event) }} onPointerDown={event => event.stopPropagation()} size="icon-titlebar" diff --git a/apps/desktop/src/app/shell/use-group-registry.ts b/apps/desktop/src/app/shell/use-group-registry.ts deleted file mode 100644 index ef78dfde0aa7..000000000000 --- a/apps/desktop/src/app/shell/use-group-registry.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useCallback, useMemo, useState } from 'react' - -type Side = 'left' | 'right' -type Groups<T> = Record<Side, Record<string, readonly T[]>> - -export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void - -interface GroupRegistry<T> { - flat: { left: T[]; right: T[] } - set: GroupSetter<T> -} - -export function useGroupRegistry<T>(): GroupRegistry<T> { - const [groups, setGroups] = useState<Groups<T>>({ left: {}, right: {} }) - - const set = useCallback<GroupSetter<T>>((id, items, side = 'right') => { - setGroups(current => { - const next = { ...current, [side]: { ...current[side] } } - - if (items.length === 0) { - delete next[side][id] - } else { - next[side][id] = items - } - - return next - }) - }, []) - - const flat = useMemo( - () => ({ - left: Object.values(groups.left).flat(), - right: Object.values(groups.right).flat() - }), - [groups] - ) - - return { flat, set } -} diff --git a/apps/desktop/src/app/skills/hub.tsx b/apps/desktop/src/app/skills/hub.tsx index a7ed3ca8fe5b..1af1b672d26f 100644 --- a/apps/desktop/src/app/skills/hub.tsx +++ b/apps/desktop/src/app/skills/hub.tsx @@ -323,7 +323,9 @@ export function SkillsHub({ query }: SkillsHubProps) { <div className="flex shrink-0 items-center justify-between gap-3 px-4 pb-1.5 text-[0.68rem] text-(--ui-text-tertiary)"> <span className="min-w-0 truncate"> {term.length > 0 ? h.resultCount(results.length, null) : h.featured} - {anyFetching && results.length > 0 && <span className="ml-2 text-(--ui-text-quaternary)">{h.searching}</span>} + {anyFetching && results.length > 0 && ( + <span className="ml-2 text-(--ui-text-quaternary)">{h.searching}</span> + )} </span> {hasInstalled && ( diff --git a/apps/desktop/src/app/skills/index.test.tsx b/apps/desktop/src/app/skills/index.test.tsx index fe3e39a72c76..f4057a4f2b88 100644 --- a/apps/desktop/src/app/skills/index.test.tsx +++ b/apps/desktop/src/app/skills/index.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { QueryClientProvider } from '@tanstack/react-query' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -48,9 +48,11 @@ function toolset(overrides: Record<string, unknown> = {}) { } } -function renderSkills() { - return import('./index').then(({ SkillsView }) => - render( +async function renderSkills() { + const { SkillsView } = await import('./index') + let result: ReturnType<typeof render> + await act(async () => { + result = render( // SkillsView reads skills/toolsets via useQuery, so it needs a provider. <QueryClientProvider client={queryClient}> <MemoryRouter initialEntries={['/skills?tab=toolsets']}> @@ -58,7 +60,9 @@ function renderSkills() { </MemoryRouter> </QueryClientProvider> ) - ) + }) + + return result! } beforeEach(() => { @@ -83,7 +87,9 @@ describe('SkillsView toolset management', () => { const sw = await screen.findByRole('switch', { name: 'Toggle Web Search toolset' }) expect(sw.getAttribute('aria-checked')).toBe('true') - fireEvent.click(sw) + await act(async () => { + fireEvent.click(sw) + }) await waitFor(() => expect(toggleToolset).toHaveBeenCalledWith('web', false)) }) diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 41620e2c1243..ccbcd5c9b41e 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -492,7 +492,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p try { await editLearningNode(skillEditor.name, skillDraft) - notify({ kind: 'success', title: t.skills.skillUpdated, message: t.skills.appliesToNewSessions(skillEditor.name) }) + notify({ + kind: 'success', + title: t.skills.skillUpdated, + message: t.skills.appliesToNewSessions(skillEditor.name) + }) setSkillEditor(null) void refreshCapabilities() } catch (err) { @@ -577,7 +581,9 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p left={sortButton(skillsSortDesc, () => $skillsSortDesc.set(!$skillsSortDesc.get()))} right={ <ListStripMenu - items={[{ disabled: bulkBusy, label: t.skills.disableUnused, onSelect: () => void disableUnused() }]} + items={[ + { disabled: bulkBusy, label: t.skills.disableUnused, onSelect: () => void disableUnused() } + ]} label={t.skills.tabSkills} toggle={bulkSwitch(allSkillsEnabled)} /> diff --git a/apps/desktop/src/app/skills/mcp-tab.tsx b/apps/desktop/src/app/skills/mcp-tab.tsx index 6098a7a456e8..ce635c48fc16 100644 --- a/apps/desktop/src/app/skills/mcp-tab.tsx +++ b/apps/desktop/src/app/skills/mcp-tab.tsx @@ -461,11 +461,36 @@ export function McpTab({ gateway }: { gateway: HermesGateway | null }) { const draftSeeded = useRef(false) useEffect(() => { - if (config && !draftSeeded.current) { + // profilePending: config still holds the PREVIOUS profile's record right + // after a switch — seeding from it would latch the wrong profile's doc. + if (!config || profilePending) { + return + } + + if (!draftSeeded.current) { draftSeeded.current = true resetDraft(getServers(config)) + + return } - }, [config]) + + if (dirty || names.length === 0) { + return + } + + // Heal the early-boot race: the first config snapshot can land before the + // backend has mcp_servers assembled, seeding (and latching) an empty doc + // while later refetches fill the list — saving would then wipe the real + // servers. A PRISTINE empty draft reseeds when servers arrive; any user + // edit (dirty) still always wins. + try { + if (Object.keys(parseServersDoc(draft)).length === 0) { + resetDraft(servers) + } + } catch { + // Mid-edit / invalid JSON — the user's text wins. + } + }, [config, dirty, draft, names, profilePending, servers]) // Bumped on every profile switch. Async probe/auth completions capture the // epoch at call time and bail if it changed, so a slow profile-A request can't @@ -698,15 +723,17 @@ export function McpTab({ gateway }: { gateway: HermesGateway | null }) { return } + const next = withEnabled(servers[serverName], enabled) + try { - if (!(await persist({ ...servers, [serverName]: withEnabled(servers[serverName], enabled) }))) { + if (!(await persist({ ...servers, [serverName]: next }))) { return } if (dirty) { patchDraft(doc => (doc[serverName] ? { ...doc, [serverName]: withEnabled(doc[serverName], enabled) } : doc)) } else { - resetDraft({ ...servers, [serverName]: withEnabled(servers[serverName], enabled) }) + resetDraft({ ...servers, [serverName]: next }) } if (enabled) { diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 1adc2bdec4e1..129acd3179dd 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -1,6 +1,7 @@ import type * as React from 'react' import type { ChatMessage } from '@/lib/chat-messages' +import type { UsageStats } from '@/types/hermes' export interface ContextSuggestion { text: string @@ -125,7 +126,8 @@ export type CommandDispatchResponse = export type SidebarNavId = 'artifacts' | 'command-center' | 'messaging' | 'new-session' | 'settings' | 'skills' export interface SidebarNavItem { - id: SidebarNavId + /** Built-in view id, or a contributed row's namespaced contribution id. */ + id: SidebarNavId | (string & {}) label: string icon: React.ComponentType<{ className?: string }> route?: string @@ -158,4 +160,8 @@ export interface ClientSessionState { * focused, and switching sessions doesn't zero a still-running turn's clock. * The global $turnStartedAt mirrors whichever session is currently viewed. */ turnStartedAt: number | null + /** Cumulative token usage, updated per completed turn. Per-session twin of + * the primary-only $currentUsage — the statusbar reads it for a focused + * tile's context count. Null until the first turn reports. */ + usage: null | UsageStats } diff --git a/apps/desktop/src/components/Backdrop.tsx b/apps/desktop/src/components/Backdrop.tsx index 1ced2f4d115d..31525b31ba2d 100644 --- a/apps/desktop/src/components/Backdrop.tsx +++ b/apps/desktop/src/components/Backdrop.tsx @@ -1,6 +1,9 @@ +import { useStore } from '@nanostores/react' import { Leva, useControls } from 'leva' import { type CSSProperties, useEffect, useState } from 'react' +import { $backdrop } from '@/store/backdrop' + const BLEND_MODES = [ 'normal', 'multiply', @@ -25,6 +28,7 @@ const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/ export function Backdrop() { const [controlsOpen, setControlsOpen] = useState(false) + const on = useStore($backdrop) useEffect(() => { if (!import.meta.env.DEV) { @@ -87,7 +91,7 @@ export function Backdrop() { <> <Leva collapsed hidden={!import.meta.env.DEV || !controlsOpen} titleBar={{ title: 'backdrop', drag: true }} /> - {statue.enabled && ( + {on && statue.enabled && ( <div aria-hidden className="pointer-events-none absolute inset-0 z-2" diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx new file mode 100644 index 000000000000..319a633c237d --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx @@ -0,0 +1,113 @@ +import type { ToolCallMessagePartProps } from '@assistant-ui/react' +import { cleanup, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' + +import { ClarifyTool, readClarifyResult } from './clarify-tool' + +afterEach(() => { + cleanup() +}) + +function renderClarify(ui: ReactNode) { + return render( + <I18nProvider configClient={null} initialLocale="en"> + {ui} + </I18nProvider> + ) +} + +function settledClarifyProps( + args: ToolCallMessagePartProps['args'], + result: ToolCallMessagePartProps['result'], + toolCallId: string +): ToolCallMessagePartProps { + return { + addResult: vi.fn(), + args, + argsText: JSON.stringify(args), + isError: false, + respondToApproval: vi.fn(), + result, + resume: vi.fn(), + status: { type: 'complete' }, + toolCallId, + toolName: 'clarify', + type: 'tool-call' + } +} + +describe('readClarifyResult', () => { + it('reads question + user_response from the tool JSON payload', () => { + expect( + readClarifyResult({ + question: 'Which target?', + choices_offered: ['staging', 'prod'], + user_response: 'staging' + }) + ).toEqual({ + question: 'Which target?', + answer: 'staging', + error: undefined + }) + }) + + it('parses a JSON string result the same way as an object', () => { + expect( + readClarifyResult( + JSON.stringify({ + question: 'Ship it?', + user_response: 'yes' + }) + ) + ).toEqual({ + question: 'Ship it?', + answer: 'yes', + error: undefined + }) + }) + + it('keeps an empty user_response so Skip can render as skipped', () => { + expect(readClarifyResult({ question: 'Ok?', user_response: '' })).toEqual({ + question: 'Ok?', + answer: '', + error: undefined + }) + }) +}) + +describe('ClarifyTool settled view', () => { + it('keeps the question and answer visible after the tool completes', () => { + renderClarify( + <ClarifyTool + {...settledClarifyProps( + { question: 'Which deployment target?', choices: ['staging', 'prod'] }, + { + question: 'Which deployment target?', + choices_offered: ['staging', 'prod'], + user_response: 'staging' + }, + 'clarify-1' + )} + /> + ) + + expect(screen.getByText('Which deployment target?')).toBeTruthy() + expect(screen.getByText('staging')).toBeTruthy() + expect(document.querySelector('[data-clarify-settled]')).toBeTruthy() + expect(document.querySelector('[data-clarify-answer]')?.textContent).toBe('staging') + }) + + it('labels an empty response as Skipped', () => { + renderClarify( + <ClarifyTool + {...settledClarifyProps({ question: 'Anything else?' }, { question: 'Anything else?', user_response: '' }, 'clarify-2')} + /> + ) + + expect(screen.getByText('Anything else?')).toBeTruthy() + expect(screen.getByText('Skipped')).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index ed3ee6be8d47..72b6fbbdfa81 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -13,61 +13,81 @@ import { useState } from 'react' +import { useSessionView } from '@/app/chat/session-view' import { ToolFallback } from '@/components/assistant-ui/tool/fallback' import { Button } from '@/components/ui/button' import { Kbd } from '@/components/ui/kbd' import { Textarea } from '@/components/ui/textarea' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { Loader2, MessageQuestion } from '@/lib/icons' +import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons' import { cn } from '@/lib/utils' -import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify' +import { clearClarifyRequest, sessionClarifyRequest } from '@/store/clarify' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { selectMessageRunning } from './tool/fallback-model' +import { parseMaybeObject } from './tool/fallback-model/format' interface ClarifyArgs { question?: string choices?: string[] | null } -function readClarifyArgs(args: unknown): ClarifyArgs { - if (!args || typeof args !== 'object') { - return {} - } +interface ClarifyResult { + question?: string + answer?: string + error?: string +} - const row = args as Record<string, unknown> +function stringField(row: Record<string, unknown>, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = row[key] + + if (typeof value === 'string') { + return value + } + } +} + +function readClarifyArgs(args: unknown): ClarifyArgs { + const row = parseMaybeObject(args) const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null return { - question: typeof row.question === 'string' ? row.question : undefined, + question: stringField(row, 'question'), choices: choices && choices.length > 0 ? choices : null } } -// Each option (and "Other") is keyed A, B, C… so it can be picked by pressing -// that letter — the badge doubles as the shortcut hint. +/** Parse clarify tool JSON (`question` + `user_response`). */ +export function readClarifyResult(result: unknown): ClarifyResult { + const row = parseMaybeObject(result) + + if (Object.keys(row).length === 0) { + return typeof result === 'string' && result.trim() ? { answer: result.trim() } : {} + } + + return { + question: stringField(row, 'question'), + answer: stringField(row, 'user_response', 'answer'), + error: stringField(row, 'error') + } +} + const letterFor = (index: number): string => String.fromCharCode(65 + index) -// Choice and "Other" rows share a layout; only color differs. Mirrors a tool -// row's compact rhythm so the panel reads as part of the transcript. const OPTION_ROW_CLASS = 'flex w-full items-start gap-2 rounded-[0.25rem] px-1.5 py-1 text-left disabled:cursor-not-allowed disabled:opacity-50' -// Content-sizing freeform field (CSS `field-sizing` — same primitive as the -// commit bar and search field): starts at one line, grows with what's typed, -// and never reflows the panel when focused. Bare so the "Other" row matches the -// choice rows above it. -const FREEFORM_INPUT_CLASS = - 'field-sizing-content max-h-40 min-h-0 w-full resize-none bg-transparent p-0 leading-(--conversation-line-height) text-(--ui-text-primary) outline-none placeholder:text-(--ui-text-tertiary) disabled:opacity-50' +// field-sizing on top of Textarea's shared chrome; kill min-h-16 for one-liners. +const CLARIFY_TEXTAREA_CLASS = 'field-sizing-content max-h-40 min-h-0 resize-none' -// Quiet inline panel that matches the surrounding tool rows: a single hairline -// border in the shared stroke token, a soft surface fill, and a faint primary -// accent that signals "this one needs you" without the loud animated ring. const CLARIFY_SHELL_CLASS = 'my-1.5 rounded-md border border-primary/20 bg-(--ui-chat-surface-background) text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)' +const CLARIFY_ICON_CLASS = 'mt-px size-4 shrink-0 text-(--ui-text-tertiary)' + function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>) { return ( <div className={cn(CLARIFY_SHELL_CLASS, className)} data-slot="clarify-inline" {...props}> @@ -76,10 +96,20 @@ function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>) ) } -// Selection lives on the letter badge alone — a solid primary fill — not the -// whole row, which stays a quiet hover target. `preview` is the focused-but-empty -// "Other" state: the badge outlines in primary to show it's armed, then fills -// once a value is actually typed. +function ClarifyLine({ + children, + className, + icon: Icon, + ...props +}: ComponentProps<'div'> & { icon: typeof MessageQuestion }) { + return ( + <div className={cn('flex items-start gap-2', className)} {...props}> + <div className="min-w-0 flex-1">{children}</div> + <Icon aria-hidden className={CLARIFY_ICON_CLASS} /> + </div> + ) +} + function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean; selected: boolean }) { return ( <Kbd @@ -96,25 +126,70 @@ function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean } export const ClarifyTool = (props: ToolCallMessagePartProps) => { + // Answered → settled Q&A (ToolFallback collapsed the answer away). + if (props.result !== undefined) { + return <ClarifyToolSettled {...props} /> + } + + return <ClarifyToolLive {...props} /> +} + +function ClarifyToolLive(props: ToolCallMessagePartProps) { const messageRunning = useAuiState(selectMessageRunning) - // Only the live, still-blocked turn shows the interactive panel. Once the - // message stops running — answered, the turn ended, or the user hit Stop — - // fall back to the standard tool block so the Q/A settles like every other - // row instead of stranding a dead prompt the gateway no longer waits on. - const isPending = messageRunning && props.result === undefined - - if (!isPending) { + // Stopped mid-prompt with no result — don't leave a dead interactive panel. + if (!messageRunning) { return <ToolFallback {...props} /> } return <ClarifyToolPending {...props} /> } +function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) { + const { t } = useI18n() + const copy = t.assistant.clarify + const fromArgs = useMemo(() => readClarifyArgs(args), [args]) + const fromResult = useMemo(() => readClarifyResult(result), [result]) + + const question = fromResult.question || fromArgs.question || '' + const answer = fromResult.answer + const error = fromResult.error + const skipped = !error && answer !== undefined && !answer.trim() + const answerText = error || (skipped ? copy.skipped : (answer ?? '').trim()) + + return ( + <ClarifyShell className="grid gap-1.5 px-2.5 py-2" data-clarify-settled=""> + {question ? ( + <ClarifyLine icon={MessageQuestion}> + <span className="whitespace-pre-wrap font-medium leading-(--conversation-line-height)">{question}</span> + </ClarifyLine> + ) : null} + {answerText ? ( + <ClarifyLine icon={CircleLetterA}> + <p + className={cn( + 'whitespace-pre-wrap leading-(--conversation-line-height)', + error ? 'text-destructive' : 'text-(--ui-text-secondary)', + skipped && 'italic text-(--ui-text-tertiary)' + )} + data-clarify-answer="" + > + {answerText} + </p> + </ClarifyLine> + ) : null} + </ClarifyShell> + ) +} + function ClarifyToolPending({ args }: ToolCallMessagePartProps) { const { t } = useI18n() const copy = t.assistant.clarify - const request = useStore($clarifyRequest) + // The tool row is in whichever session's transcript rendered it — read THAT + // session's clarify (primary or tile), not the globally-active one. + const sessionId = useStore(useSessionView().$runtimeId) + const $request = useMemo(() => sessionClarifyRequest(sessionId), [sessionId]) + const request = useStore($request) const gateway = useStore($gateway) const fromArgs = useMemo(() => readClarifyArgs(args), [args]) @@ -175,8 +250,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { }) triggerHaptic('submit') clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId) - // The matching tool.complete will land shortly after, swapping this - // panel for the ToolFallback view above. + // tool.complete lands next → ClarifyToolSettled. } catch (error) { notifyError(error, copy.sendFailed) setSubmitting(false) @@ -327,17 +401,13 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { <span className="flex-1 wrap-anywhere">{choice}</span> </button> ))} - {/* "Other" is an inline content-sizing field, not a separate view. */} - <label className={cn(OPTION_ROW_CLASS, 'focus-within:bg-(--chrome-action-hover)')}> + <label className={cn(OPTION_ROW_CLASS, 'items-center')}> <KeyBadge char={letterFor(choices.length)} preview={otherFocused} selected={Boolean(trimmedDraft)} /> - <textarea - className={FREEFORM_INPUT_CLASS} + <Textarea + className={CLARIFY_TEXTAREA_CLASS} disabled={submitting} onBlur={() => setOtherFocused(false)} onChange={event => onDraftChange(event.target.value)} - // Focusing "Other" is a switch to typing your own answer, so it - // deselects any picked choice — a chosen option and an active - // Other field can never both look selected. onFocus={() => { setSelectedChoice(null) setOtherFocused(true) @@ -346,19 +416,21 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { placeholder={copy.other} ref={textareaRef} rows={1} + size="sm" value={draft} /> </label> </div> ) : ( <Textarea - className={FREEFORM_INPUT_CLASS} + className={CLARIFY_TEXTAREA_CLASS} disabled={submitting} onChange={event => onDraftChange(event.target.value)} onKeyDown={handleTextareaKey} placeholder={copy.placeholder} ref={textareaRef} rows={1} + size="sm" value={draft} /> )} diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.test.ts b/apps/desktop/src/components/assistant-ui/markdown-text.test.ts index b3ea416d0664..748bef09e288 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.test.ts +++ b/apps/desktop/src/components/assistant-ui/markdown-text.test.ts @@ -210,4 +210,29 @@ describe('preprocessMarkdown', () => { expect(() => preprocessMarkdown(input)).not.toThrow() }) + + it('keeps $$<digit>$$ display math intact instead of escaping it as currency', () => { + const output = preprocessMarkdown('$$5x = 10$$') + + expect(output).toContain('$$5x = 10$$') + expect(output).not.toContain('\\$') + }) + + it('rewrites double-backslash bracket math to dollar delimiters', () => { + const output = preprocessMarkdown('\\\\(x^2\\\\)') + + expect(output).toContain('$x^2$') + }) + + it('rewrites [/math] and [/inline] tag pairs to dollar delimiters', () => { + expect(preprocessMarkdown('[/math]a+b[/math]')).toContain('$$a+b$$') + expect(preprocessMarkdown('[/inline]x[/inline]')).toContain('$x$') + }) + + it('escapes currency dollars in prose so they are not parsed as math', () => { + const output = preprocessMarkdown('$5 and $10') + + expect(output).toContain('\\$5') + expect(output).toContain('\\$10') + }) }) diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index bceadb8e6ca0..8f17685108ff 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -5,19 +5,11 @@ import { parseMarkdownIntoBlocks, type StreamdownTextComponents, StreamdownTextPrimitive, - type SyntaxHighlighterProps + type SyntaxHighlighterProps, + tailBoundedRemend } from '@assistant-ui/react-streamdown' import { code } from '@streamdown/code' -import { - type ComponentProps, - memo, - type ReactNode, - useDeferredValue, - useEffect, - useMemo, - useRef, - useState -} from 'react' +import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react' import { ExpandableBlock } from '@/components/chat/expandable-block' import { PreviewAttachment } from '@/components/chat/preview-attachment' @@ -38,7 +30,6 @@ import { mediaStreamUrl } from '@/lib/media' import { previewTargetFromMarkdownHref } from '@/lib/preview-targets' -import { tailBoundedRemend } from '@/lib/remend-tail' import { cn } from '@/lib/utils' import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds' @@ -58,8 +49,8 @@ import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } fro const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true }) // Replaces Streamdown's `parseIncompleteMarkdown` (full-text remend per -// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay -// module-scope so the prop identity is stable across renders. +// flush) with a tail-bounded repair. Must stay module-scope so the prop +// identity is stable across renders. function preprocessWithTailRepair(text: string): string { try { return tailBoundedRemend(preprocessMarkdown(text)) @@ -71,8 +62,7 @@ function preprocessWithTailRepair(text: string): string { // Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full // `marked` lex of the entire message, ~1.6ms per 28KB) inside a useMemo keyed // on the text — but the same text is re-lexed every time a message REMOUNTS -// (virtualizer scroll, session switch) and whenever multiple surfaces render -// the same content (deferred + smooth reveal republish). A small module-level +// (virtualizer scroll, session switch). A small module-level // LRU keyed by the exact source string removes all of those repeat parses // with zero correctness risk (same input → same output). Streaming tail // growth misses the cache by design (every flush is a new string) — that @@ -348,150 +338,10 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) ) } -// Steady character-reveal for streaming text: decouples visible cadence from -// bursty arrival so text flows instead of popping (cf. assistant-ui's useSmooth, -// reimplemented for a tunable rate). Proportional drain — each frame reveals a -// slice of the backlog so the reveal converges within ~REVEAL_DRAIN_MS whatever -// the size; the per-frame cap stops a huge dump rendering as one slab. The loop -// is gated on backlog, not isRunning, so a stream that completes mid-reveal -// keeps draining its tail instead of snapping. -const REVEAL_DRAIN_MS = 500 -const REVEAL_MAX_CHARS_PER_FRAME = 30 -// Floor between reveal commits. Each commit republishes the text context and -// re-runs the whole Streamdown pipeline (preprocess → remend → lex → micromark -// on the open block) over the full accumulated text — at raw rAF cadence -// that's 60 full parses/second and was the dominant streaming cost for -// reasoning text. ~33ms keeps the reveal visually fluid (2 frames) while -// halving the parse work. -const REVEAL_MIN_COMMIT_MS = 33 - -function useSmoothReveal(text: string, isRunning: boolean): string { - const [displayed, setDisplayed] = useState(isRunning ? '' : text) - const targetRef = useRef(text) - const shownRef = useRef(displayed) - const frameRef = useRef<number | null>(null) - const lastTickRef = useRef(0) - - shownRef.current = displayed - targetRef.current = text - - useEffect(() => { - if (typeof window === 'undefined') { - return - } - - // Non-extending change (regenerate / branch / history swap): restart from - // empty while streaming, else snap to the replacement. - if (!text.startsWith(shownRef.current)) { - shownRef.current = isRunning ? '' : text - setDisplayed(shownRef.current) - } - - if (shownRef.current.length >= text.length || frameRef.current !== null) { - return - } - - lastTickRef.current = performance.now() - - const tick = () => { - const now = performance.now() - const dt = now - lastTickRef.current - - // Skip this frame if the floor hasn't elapsed — the backlog math below - // is dt-proportional, so delayed commits reveal proportionally more. - if (dt < REVEAL_MIN_COMMIT_MS) { - frameRef.current = requestAnimationFrame(tick) - - return - } - - lastTickRef.current = now - - const remaining = targetRef.current.length - shownRef.current.length - - const add = Math.min( - remaining, - // dt-scaled so the per-commit cap stays equivalent to the old - // per-frame cap at any commit cadence. - Math.ceil((REVEAL_MAX_CHARS_PER_FRAME * dt) / 16.7), - Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS)) - ) - - shownRef.current = targetRef.current.slice(0, shownRef.current.length + add) - setDisplayed(shownRef.current) - - frameRef.current = shownRef.current.length < targetRef.current.length ? requestAnimationFrame(tick) : null - } - - frameRef.current = requestAnimationFrame(tick) - }, [text, isRunning]) - - useEffect( - () => () => { - if (frameRef.current !== null && typeof window !== 'undefined') { - cancelAnimationFrame(frameRef.current) - } - }, - [] - ) - - return displayed -} - -// Re-publish the part context with a smooth character-reveal, above -// DeferStreamingText so the reveal feeds the deferred markdown pipeline. Status -// stays running while revealing so the caret persists past the underlying part -// settling. -function SmoothStreamingText({ children }: { children: ReactNode }) { - const { text, status } = useMessagePartText() - const isRunning = status.type === 'running' - const revealed = useSmoothReveal(text, isRunning) - - return ( - <TextMessagePartProvider isRunning={isRunning || revealed !== text} text={revealed}> - {children} - </TextMessagePartProvider> - ) -} - -/** - * Re-publish the active message-part context with React's `useDeferredValue` - * applied to the streaming text and status. The outer wrapper still re-renders - * on every token, but the work it does is trivial (one hook, one provider). - * - * The expensive subtree (Streamdown → micromark → mdast → hast → React) lives - * inside `<TextMessagePartProvider>` and reads the deferred text via the - * normal `useMessagePartText` hook. React's concurrent scheduler then has - * permission to: - * - skip intermediate token states when the next token arrives mid-render - * (it abandons the in-flight deferred render and starts over) - * - deprioritize the markdown render when the main thread is busy with an - * urgent task (typing, scrolling, layout work elsewhere) - * - * Net effect: per-token CPU is unchanged but the *blocking* part of that work - * goes away — typing-while-streaming stays a single-frame paint, scroll - * stutter disappears, and the longtask histogram tightens because long - * commits can be interrupted and discarded. - * - * Industry standard (Streamdown's own block-array setState already uses - * `useTransition`); this just lifts the deferral up to the consumer text - * boundary so it covers the whole pipeline, not just the inner setState. - */ -function DeferStreamingText({ children }: { children: ReactNode }) { - const { text, status } = useMessagePartText() - const deferredText = useDeferredValue(text) - const isRunning = status.type === 'running' - - return ( - <TextMessagePartProvider isRunning={isRunning} text={deferredText}> - {children} - </TextMessagePartProvider> - ) -} - interface MarkdownTextSurfaceProps { containerClassName?: string containerProps?: ComponentProps<'div'> + defer?: boolean } // Headings shrink to chat scale rather than the prose default (h1≈xl). Kept @@ -540,7 +390,7 @@ function HugeTextFallback({ containerClassName, text }: { containerClassName?: s ) } -function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) { +function MarkdownTextSurface({ containerClassName, containerProps, defer }: MarkdownTextSurfaceProps) { const { status, text } = useMessagePartText() const isStreaming = status.type === 'running' @@ -668,18 +518,14 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex components={components} containerClassName={cn(MARKDOWN_CONTAINER_CLASS_NAME, containerClassName)} containerProps={containerProps} + defer={defer} lineNumbers={false} mode="streaming" - // Incomplete-markdown repair is handled by `preprocessWithTailRepair` - // below (tail-bounded remend) instead of Streamdown's built-in pass, - // which re-runs remend over the ENTIRE message on every flush — ~18% - // of streaming script time on 50KB+ messages. The repair itself stays - // always-on (even between flushes / for completed messages): an - // unclosed ```python ... ``` whose body contains `$` (shell snippets, - // JS template strings, dollar amounts) would otherwise leak those - // dollars to the math parser and render broken inline math. Shiki is - // independently deferred via `defer={isStreaming}` on the - // SyntaxHighlighter component. + // Incomplete-markdown repair runs in preprocessWithTailRepair on the + // full accumulated text; the built-in tail-bounded remend is disabled + // because a custom parseMarkdownIntoBlocksFn is supplied, and + // parseIncompleteMarkdown stays false to avoid a second full-text + // remend pass. parseIncompleteMarkdown={false} parseMarkdownIntoBlocksFn={parseMarkdownIntoBlocksCached} plugins={plugins} @@ -694,23 +540,21 @@ interface MarkdownTextContentProps extends MarkdownTextSurfaceProps { } export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: MarkdownTextContentProps) { + // No `smooth` on purpose — same as the assistant answer. `TextMessagePartProvider` + // mints a fresh part object on every `text` change, and useSmooth resets its + // reveal to empty whenever the part identity changes, so a smoothed reasoning + // stream re-types from the first character on every delta (the flash). Token- + // streaming reasoners (R1/Qwen/GLM/Claude thinking) hit it hardest; GPT-5's + // coarse summary updates too rarely to notice. Plain append matches the answer. return ( <TextMessagePartProvider isRunning={isRunning} text={text}> - <SmoothStreamingText> - <DeferStreamingText> - <MarkdownTextSurface {...surfaceProps} /> - </DeferStreamingText> - </SmoothStreamingText> + <MarkdownTextSurface defer {...surfaceProps} /> </TextMessagePartProvider> ) } const MarkdownTextImpl = () => { - return ( - <DeferStreamingText> - <MarkdownTextSurface /> - </DeferStreamingText> - ) + return <MarkdownTextSurface defer /> } export const MarkdownText = memo(MarkdownTextImpl) diff --git a/apps/desktop/src/components/assistant-ui/message-render-boundary.test.tsx b/apps/desktop/src/components/assistant-ui/message-render-boundary.test.tsx index 934f3babd1c0..8e1b70f7934f 100644 --- a/apps/desktop/src/components/assistant-ui/message-render-boundary.test.tsx +++ b/apps/desktop/src/components/assistant-ui/message-render-boundary.test.tsx @@ -13,7 +13,7 @@ function Boom({ error }: { error: Error | null }): null { return null } -const lookupError = new Error('tapClientLookup: Index 2 out of bounds (length: 2)') +const lookupError = new Error('useClientLookup: Index 2 out of bounds (length: 2)') describe('MessageRenderBoundary', () => { it('renders children when nothing throws', () => { @@ -26,7 +26,7 @@ describe('MessageRenderBoundary', () => { expect(screen.getByText('content')).toBeTruthy() }) - it('swallows the transient tapClientLookup out-of-bounds store race', () => { + it('swallows the transient useClientLookup out-of-bounds store race', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const { container } = render( diff --git a/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx b/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx index 990f8c6072e4..f0fafbc4f18f 100644 --- a/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx +++ b/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx @@ -1,6 +1,6 @@ import { Component, type ReactNode } from 'react' -// `@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`) +// `@assistant-ui/store`'s index-keyed child-scope lookup (`useClientLookup`) // throws — rather than returning undefined — when a subscriber reads an index // that the message/parts list no longer has. This races during high-frequency // store replacement (session switch mid-stream, gateway reconnect replay): a @@ -10,7 +10,7 @@ import { Component, type ReactNode } from 'react' // without a local boundary it unwinds to the root and blanks the whole app. // Upstream-tracked: assistant-ui/assistant-ui#4051, #3652. const isTransientLookupError = (error: unknown): boolean => - error instanceof Error && /tapClient(Lookup|Resource).*out of bounds/.test(error.message) + error instanceof Error && /(useClientLookup|tapClient(Lookup|Resource)).*out of bounds/.test(error.message) interface Props { // Changes whenever the message list mutates; remounting clears the caught diff --git a/apps/desktop/src/components/assistant-ui/thread/block-direction.test.tsx b/apps/desktop/src/components/assistant-ui/thread/block-direction.test.tsx index cc63d4fbbe38..227f9e63b025 100644 --- a/apps/desktop/src/components/assistant-ui/thread/block-direction.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/block-direction.test.tsx @@ -25,6 +25,7 @@ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0) ) vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) +vi.stubGlobal('CSS', { escape: (str: string) => str }) Element.prototype.scrollTo = function scrollTo() {} diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index ccd672dac9f2..0e1a2e7e0882 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -1,4 +1,9 @@ -import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' +import { + type ReasoningMessagePartComponent, + type ToolCallMessagePartProps, + useAuiState, + useMessagePartReasoning +} from '@assistant-ui/react' import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useState } from 'react' import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' @@ -174,17 +179,19 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star ) } -const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => { - const displayText = text.trimStart() +// Read the part from context, same contract as MarkdownText's +// useMessagePartText — the reasoning-only smoothing wrapper (removed) stalled +// the char-reveal at empty, blanking the widget. +const ReasoningTextPart: ReasoningMessagePartComponent = () => { + const { status, text } = useMessagePartReasoning() const messageRunning = useAuiState(s => s.message.status?.type === 'running') - const isRunning = status?.type === 'running' || messageRunning return ( <MarkdownTextContent containerClassName="text-xs leading-snug text-muted-foreground/85" containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>} - isRunning={isRunning} - text={displayText} + isRunning={status.type === 'running' || messageRunning} + text={text.trimStart()} /> ) } diff --git a/apps/desktop/src/components/assistant-ui/thread/status.test.tsx b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx new file mode 100644 index 000000000000..b4920e1ec483 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx @@ -0,0 +1,56 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { __resetElapsedTimerRegistryForTests } from '@/components/chat/activity-timer' +import { I18nProvider } from '@/i18n' +import { $activeSessionId, $turnStartedAt } from '@/store/session' + +import { ResponseLoadingIndicator } from './status' + +function renderIndicator() { + return render( + <I18nProvider configClient={null} initialLocale="en"> + <ResponseLoadingIndicator /> + </I18nProvider> + ) +} + +describe('ResponseLoadingIndicator timer', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + __resetElapsedTimerRegistryForTests() + }) + + afterEach(() => { + cleanup() + $activeSessionId.set(null) + $turnStartedAt.set(null) + __resetElapsedTimerRegistryForTests() + vi.useRealTimers() + }) + + it('preserves each running session timer while switching between sessions', () => { + $activeSessionId.set('session-a') + $turnStartedAt.set(Date.now()) + const sessionA = renderIndicator() + + act(() => vi.advanceTimersByTime(5_000)) + expect(screen.getByText('5s')).toBeTruthy() + sessionA.unmount() + + $activeSessionId.set('session-b') + $turnStartedAt.set(Date.now()) + const sessionB = renderIndicator() + + act(() => vi.advanceTimersByTime(3_000)) + expect(screen.getByText('3s')).toBeTruthy() + sessionB.unmount() + + $activeSessionId.set('session-a') + $turnStartedAt.set(new Date('2026-01-01T00:00:00.000Z').getTime()) + renderIndicator() + + expect(screen.getByText('8s')).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/status.tsx b/apps/desktop/src/components/assistant-ui/thread/status.tsx index 53c6f415a85f..f65793d18ca8 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.tsx @@ -11,6 +11,7 @@ import { cn } from '@/lib/utils' import { $backgroundResume } from '@/store/background-delegation' import { $compactionActive } from '@/store/compaction' import { $activeSessionAwaitingInput } from '@/store/prompts' +import { $activeSessionId, $turnStartedAt } from '@/store/session' const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({ children, @@ -36,6 +37,13 @@ const CompactionHint: FC = () => ( <span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span> ) +function useActiveTurnTimerKey(): string | undefined { + const activeSessionId = useStore($activeSessionId) + const turnStartedAt = useStore($turnStartedAt) + + return activeSessionId && turnStartedAt ? `turn:${activeSessionId}:${turnStartedAt}` : undefined +} + export const CenteredThreadSpinner: FC = () => { const { t } = useI18n() @@ -59,7 +67,8 @@ export const CenteredThreadSpinner: FC = () => { export const ResponseLoadingIndicator: FC = () => { const { t } = useI18n() - const elapsed = useElapsedSeconds() + const timerKey = useActiveTurnTimerKey() + const elapsed = useElapsedSeconds(true, timerKey) const compacting = useStore($compactionActive) return ( @@ -134,6 +143,7 @@ export const StreamStallIndicator: FC = () => { const [stalled, setStalled] = useState(false) const compacting = useStore($compactionActive) + const turnTimerKey = useActiveTurnTimerKey() // A pending clarify / approval / sudo / secret means the turn is paused on the // user, not working — so don't resurrect the "thinking" timer while they // decide (matches the pet's awaitingInput pose taking priority over busy). @@ -147,7 +157,7 @@ export const StreamStallIndicator: FC = () => { }, [activity]) const active = (stalled || compacting) && !awaitingInput - const elapsed = useElapsedSeconds(active) + const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined) if (!active) { return null diff --git a/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx index 3759f469e2f5..fb844df0dc2e 100644 --- a/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx @@ -48,6 +48,7 @@ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0) ) vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) +vi.stubGlobal('CSS', { escape: (str: string) => str }) Element.prototype.scrollTo = function scrollTo() {} diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index b292f3f90bb8..70a5472e585c 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -57,6 +57,7 @@ import { Codicon } from '@/components/ui/codicon' import type { HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime' +import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { Loader2Icon } from '@/lib/icons' @@ -188,7 +189,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess const syncDraftFromEditor = useCallback( (editor: HTMLDivElement) => { - const nextDraft = composerPlainText(editor) + const nextDraft = sanitizeComposerInput(composerPlainText(editor)) if (nextDraft !== draftRef.current) { draftRef.current = nextDraft @@ -477,7 +478,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess } const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => { - const pastedText = event.clipboardData.getData('text') + const pastedText = sanitizeComposerInput(event.clipboardData.getData('text')) if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) { event.preventDefault() diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message-edit.test.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message-edit.test.tsx index 914fc043b123..f81bd08ec57f 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message-edit.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message-edit.test.tsx @@ -1,4 +1,4 @@ -import { ExportedMessageRepository } from '@assistant-ui/core/internal' +import { ExportedMessageRepository } from '@assistant-ui/react' // Clicking a user bubble must open the inline edit composer — through the // app's incremental external-store runtime (which reimplements capability // resolution, incl. `edit: onEdit !== undefined`) and the stock runtime. @@ -28,6 +28,7 @@ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0) ) vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) +vi.stubGlobal('CSS', { escape: (str: string) => str }) Element.prototype.scrollTo = function scrollTo() {} diff --git a/apps/desktop/src/components/assistant-ui/tool/approval.test.tsx b/apps/desktop/src/components/assistant-ui/tool/approval.test.tsx index 2955fafe8d6f..910a436f81f2 100644 --- a/apps/desktop/src/components/assistant-ui/tool/approval.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/approval.test.tsx @@ -30,9 +30,13 @@ function part(toolName: string): ToolPart { return { toolName, type: `tool-${toolName}` } as unknown as ToolPart } -function setRequest(command = 'rm -rf /tmp/x', allowPermanent?: boolean) { +function setRequest( + command = 'rm -rf /tmp/x', + allowPermanent?: boolean, + extra: { choices?: string[]; smartDenied?: boolean } = {} +) { $activeSessionId.set('sess-1') - setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1' }) + setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1', ...extra }) } function mockGateway() { @@ -131,6 +135,26 @@ describe('PendingToolApproval', () => { expect(screen.queryByRole('menuitem', { name: /Always allow/ })).toBeNull() }) + it('renders only Once and Deny for a Smart DENY owner override', () => { + setRequest('rm -rf /tmp/x', true, { smartDenied: true }) + render(<PendingToolApproval part={part('terminal')} />) + + expect(screen.getByRole('button', { name: /Run/ })).toBeTruthy() + expect(screen.getByRole('button', { name: /Reject/ })).toBeTruthy() + expect(screen.queryByRole('button', { name: /More approval options/ })).toBeNull() + expect(screen.queryByText(/Allow this session/)).toBeNull() + expect(screen.queryByText(/Always allow/)).toBeNull() + }) + + it('renders only choices explicitly supplied by the gateway event', () => { + setRequest('rm -rf /tmp/x', true, { choices: ['once', 'deny'] }) + render(<PendingToolApproval part={part('terminal')} />) + + expect(screen.getByRole('button', { name: /Run/ })).toBeTruthy() + expect(screen.getByRole('button', { name: /Reject/ })).toBeTruthy() + expect(screen.queryByRole('button', { name: /More approval options/ })).toBeNull() + }) + it('renders a floating fallback when no pending tool row is mounted', () => { setRequest('rm /tmp/hermes_approval_test.txt') const { container } = render(<PendingApprovalFallback />) diff --git a/apps/desktop/src/components/assistant-ui/tool/approval.tsx b/apps/desktop/src/components/assistant-ui/tool/approval.tsx index 3416c22727b4..a909e486fd86 100644 --- a/apps/desktop/src/components/assistant-ui/tool/approval.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/approval.tsx @@ -1,8 +1,9 @@ 'use client' import { useStore } from '@nanostores/react' -import { type FC, useCallback, useEffect, useState } from 'react' +import { type FC, useCallback, useEffect, useMemo, useState } from 'react' +import { useSessionView } from '@/app/chat/session-view' import { Button } from '@/components/ui/button' import { Dialog, @@ -20,11 +21,11 @@ import { cn } from '@/lib/utils' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { - $approvalInlineVisible, - $approvalRequest, type ApprovalRequest, clearApprovalRequest, - registerApprovalInlineAnchor + registerApprovalInlineAnchor, + sessionApprovalInlineVisible, + sessionApprovalRequest } from '@/store/prompts' import type { ToolPart } from './fallback-model' @@ -48,7 +49,11 @@ export const APPROVAL_TOOLS = new Set(['terminal', 'execute_code']) type ApprovalChoice = 'once' | 'session' | 'always' | 'deny' export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => { - const request = useStore($approvalRequest) + // The tool row lives in whichever session's transcript rendered it — read + // THAT session's approval (works for the primary and every tile). + const sessionId = useStore(useSessionView().$runtimeId) + const $request = useMemo(() => sessionApprovalRequest(sessionId), [sessionId]) + const request = useStore($request) if (!request || !APPROVAL_TOOLS.has(part.toolName)) { return null @@ -58,15 +63,18 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => { } const InlineApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => { - useEffect(() => registerApprovalInlineAnchor(), []) + useEffect(() => registerApprovalInlineAnchor(request.sessionId), [request.sessionId]) return <ApprovalBar request={request} surface="inline" /> } export const PendingApprovalFallback: FC = () => { const { t } = useI18n() - const request = useStore($approvalRequest) - const inlineVisible = useStore($approvalInlineVisible) + const sessionId = useStore(useSessionView().$runtimeId) + const $request = useMemo(() => sessionApprovalRequest(sessionId), [sessionId]) + const $inlineVisible = useMemo(() => sessionApprovalInlineVisible(sessionId), [sessionId]) + const request = useStore($request) + const inlineVisible = useStore($inlineVisible) if (!request || inlineVisible) { return null @@ -110,13 +118,18 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' const busy = submitting !== null // false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow". const allowPermanent = request.allowPermanent !== false + const choices = request.choices ?? (request.smartDenied ? ['once', 'deny'] : undefined) + const allowSession = choices ? choices.includes('session') : true + const allowAlways = choices ? choices.includes('always') : allowPermanent + const hasMoreOptions = allowSession || allowAlways const hasCommand = request.command.trim().length > 0 const respond = useCallback( async (choice: ApprovalChoice) => { // Another bar (or the keyboard path) may have already resolved this - // approval; the atom is the single source of truth, so bail if it's gone. - if (busy || !$approvalRequest.get()) { + // approval; the map is the single source of truth, so bail if this + // session's request is gone. + if (busy || !sessionApprovalRequest(request.sessionId).get()) { return } @@ -183,8 +196,8 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' {submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run} {submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>} </Button> - <span aria-hidden className="w-px self-stretch bg-primary/20" /> - <DropdownMenu> + {hasMoreOptions && <span aria-hidden className="w-px self-stretch bg-primary/20" />} + {hasMoreOptions && <DropdownMenu> <DropdownMenuTrigger asChild> <Button aria-label={copy.moreOptions} @@ -197,8 +210,8 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start" className="min-w-44"> - <DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem> - {allowPermanent && ( + {allowSession && <DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>} + {allowAlways && ( <DropdownMenuItem onSelect={() => { // Defer one tick so the menu fully unmounts before the dialog @@ -214,7 +227,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' {copy.reject} </DropdownMenuItem> </DropdownMenuContent> - </DropdownMenu> + </DropdownMenu>} </div> <Button diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts index 009abcfdf692..ea253ecadcba 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts @@ -89,7 +89,7 @@ describe('buildToolView browser_navigate title', () => { ) expect(view.status).toBe('error') - expect(view.title).toBe('Failed to open hermes-agent.nousresearch.com') + expect(view.title).toBe('Failed to open hermes-agent.nousresearch.com/docs') }) it('shows opened title on success', () => { @@ -103,7 +103,7 @@ describe('buildToolView browser_navigate title', () => { ) expect(view.status).toBe('success') - expect(view.title).toBe('Opened hermes-agent.nousresearch.com') + expect(view.title).toBe('Opened hermes-agent.nousresearch.com/docs') }) }) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts b/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts new file mode 100644 index 000000000000..53d082241933 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' + +import { shouldBoundToolGroup, UNBOUNDABLE_TOOLS } from './fallback' + +describe('shouldBoundToolGroup', () => { + it('bounds long runs of ordinary tool calls', () => { + expect(shouldBoundToolGroup(3, false)).toBe(true) + }) + + it('leaves short runs unbounded', () => { + expect(shouldBoundToolGroup(2, false)).toBe(false) + }) + + it('never bounds a run holding an unboundable tool', () => { + expect(shouldBoundToolGroup(3, true)).toBe(false) + }) +}) + +describe('UNBOUNDABLE_TOOLS', () => { + it('exempts clarify forms and generated images from the window', () => { + expect(UNBOUNDABLE_TOOLS.has('clarify')).toBe(true) + expect(UNBOUNDABLE_TOOLS.has('image_generate')).toBe(true) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index a501b7c9ee0e..705c900e3b21 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -611,6 +611,15 @@ function ToolEntry({ part }: ToolEntryProps) { // auto-scrolling window; fewer than this stays a plain inline stack. const TOOL_GROUP_SCROLL_THRESHOLD = 3 +// Tools whose body (an interactive form, a full-size image) must never be +// trapped behind the window's max-height + fade mask. A run holding any of +// them stays a plain, fully-visible stack no matter how long it is. +export const UNBOUNDABLE_TOOLS = new Set(['clarify', 'image_generate']) + +export function shouldBoundToolGroup(childCount: number, hasUnboundable: boolean) { + return childCount >= TOOL_GROUP_SCROLL_THRESHOLD && !hasUnboundable +} + // Pin-to-bottom + top-fade for the bounded tool window. Pins the newest row on // growth (a call lands or a row expands) unless the user scrolled up, and fades // the top edge once anything sits above it. Mirrors ThinkingDisclosure's live @@ -678,13 +687,21 @@ function useToolWindow(enabled: boolean) { */ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({ children, + endIndex, startIndex }) => { const messageId = useAuiState(s => s.message.id) const messageRunning = useAuiState(selectMessageRunning) + + const hasUnboundable = useAuiState(s => + s.message.parts + .slice(Math.max(0, startIndex), endIndex + 1) + .some(part => part.type === 'tool-call' && UNBOUNDABLE_TOOLS.has(part.toolName)) + ) + const enterRef = useEnterAnimation(messageRunning, `tool-group:${messageId}:${startIndex}`) - const bounded = Children.count(children) >= TOOL_GROUP_SCROLL_THRESHOLD + const bounded = shouldBoundToolGroup(Children.count(children), hasUnboundable) const { contentRef, faded, onScroll, scrollRef } = useToolWindow(bounded) return ( diff --git a/apps/desktop/src/components/boot-failure-overlay.test.tsx b/apps/desktop/src/components/boot-failure-overlay.test.tsx new file mode 100644 index 000000000000..e86f987a631a --- /dev/null +++ b/apps/desktop/src/components/boot-failure-overlay.test.tsx @@ -0,0 +1,101 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { $desktopBoot } from '@/store/boot' +import { $desktopOnboarding } from '@/store/onboarding' + +import { BootFailureOverlay } from './boot-failure-overlay' + +// Remote-backend users hit a hard boot failure that isn't OAuth reauth (token +// auth, wrong URL, unreachable host). The recovery screen must let them fix the +// remote connection in place — the "Connection settings" action swaps the card +// to an in-line connect form — instead of stranding them (the old bug forced a +// hand-edit of connection.json). + +function failBoot() { + $desktopBoot.set({ + error: 'Could not connect to Hermes gateway', + fakeMode: false, + message: 'boot failed', + phase: 'renderer.error', + progress: 40, + running: false, + timestamp: Date.now(), + visible: true + }) +} + +function stubDesktop(config: Record<string, unknown>) { + const original = window.hermesDesktop + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { getRecentLogs: async () => ({ lines: [] }), getConnectionConfig: async () => config } + }) + + return () => Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: original }) +} + +const remoteToken = { + envOverride: false, + mode: 'remote', + profile: null, + remoteAuthMode: 'token', + remoteOauthConnected: false, + remoteTokenPreview: null, + remoteTokenSet: true, + remoteUrl: 'http://100.116.104.53:9191', + cloudOrg: '' +} + +beforeEach(() => { + $desktopOnboarding.set({ + configured: true, + flow: { status: 'idle' }, + mode: 'oauth', + providers: null, + reason: null, + requested: false, + firstRunSkipped: false, + manual: false, + localEndpoint: false + }) + failBoot() +}) + +afterEach(cleanup) + +describe('BootFailureOverlay', () => { + it('swaps to the in-place gateway settings view (no route nav) and back', async () => { + render(<BootFailureOverlay />) + + fireEvent.click(screen.getByRole('button', { name: /gateway settings/i })) + // Recovery actions give way to the embedded panel (behind a Back control). + expect(await screen.findByRole('button', { name: /back/i })).toBeTruthy() + expect(screen.queryByRole('button', { name: /retry/i })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: /back/i })) + expect(screen.getByRole('button', { name: /retry/i })).toBeTruthy() + expect(screen.queryByRole('button', { name: /back/i })).toBeNull() + }) + + it('drops local-only Repair and Use-local-gateway on a local failure', () => { + render(<BootFailureOverlay />) + // No connection config stub → treated as a local failure. + expect(screen.getByRole('button', { name: /retry/i })).toBeTruthy() + expect(screen.getByRole('button', { name: /repair/i })).toBeTruthy() + expect(screen.queryByRole('button', { name: /use local gateway/i })).toBeNull() + }) + + it('leads with Gateway settings and drops Repair for a remote (token) failure', async () => { + const restore = stubDesktop(remoteToken) + + try { + render(<BootFailureOverlay />) + await waitFor(() => expect(screen.queryByRole('button', { name: /repair/i })).toBeNull()) + expect(screen.getByRole('button', { name: /gateway settings/i })).toBeTruthy() + expect(screen.getByRole('button', { name: /use local gateway/i })).toBeTruthy() + } finally { + restore() + } + }) +}) diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index bb17f79c3cd1..381bd94efec2 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -1,20 +1,29 @@ import { useStore } from '@nanostores/react' -import { useEffect, useState } from 'react' +import { type ComponentProps, lazy, type ReactNode, Suspense, useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { ErrorIcon } from '@/components/ui/error-state' +import { Loader } from '@/components/ui/loader' import { LogView } from '@/components/ui/log-view' import type { DesktopConnectionConfig } from '@/global' import { useI18n } from '@/i18n' -import { FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons' +import { ChevronLeft, FileText, Loader2, LogIn, RefreshCw, SlidersHorizontal, Wrench } from '@/lib/icons' import { $desktopBoot } from '@/store/boot' import { notify, notifyError } from '@/store/notifications' import { $desktopOnboarding } from '@/store/onboarding' import type { RemoteReauth } from './boot-failure-reauth' -import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth' +import { deriveProviderShape, isRemoteConfig, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth' + +// The recovery "Gateway settings" view embeds the real Settings → Gateway panel +// (identical URL/auth/test/save controls — no parallel form to drift). Lazy so +// it stays out of the always-mounted overlay's bundle until opened. +const GatewaySettings = lazy(() => + import('@/app/settings/gateway-settings').then(module => ({ default: module.GatewaySettings })) +) type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null +type RecoveryView = 'connect' | 'recovery' // A remote gateway whose access cookie has lapsed (e.g. the dashboard // restarted on the remote box) boots into this overlay with a reauth-shaped @@ -35,6 +44,13 @@ export function BootFailureOverlay() { const [logs, setLogs] = useState<string[]>([]) const [showLogs, setShowLogs] = useState(false) const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null) + // A remote/cloud backend that failed to boot is fixable from gateway settings, + // so the escape hatch earns emphasis (local failures keep it as a quiet ghost). + const [remoteFailure, setRemoteFailure] = useState(false) + // Swap the card body to the embedded Gateway settings panel in place of routing + // to the full Settings page (keeps the user on the recovery surface, no z-index + // juggling, no second connection form to maintain). + const [view, setView] = useState<RecoveryView>('recovery') const visible = Boolean(boot.error) && !boot.running // While first-run onboarding owns the picker/flow we let it surface its own @@ -51,7 +67,7 @@ export function BootFailureOverlay() { ?.getRecentLogs() .then(res => setLogs(res.lines ?? [])) .catch(() => undefined) - }, [visible]) + }, [boot.error, visible]) // Resolve whether this boot failure is a remote-gateway reauth so we can // offer the actionable "Sign in" path instead of the local-only recovery @@ -59,6 +75,8 @@ export function BootFailureOverlay() { useEffect(() => { if (!visible) { setRemoteReauth(null) + setRemoteFailure(false) + setView('recovery') return } @@ -80,7 +98,13 @@ export function BootFailureOverlay() { return } - if (cancelled || !isRemoteReauthFailure(config)) { + if (cancelled) { + return + } + + setRemoteFailure(isRemoteConfig(config)) + + if (!isRemoteReauthFailure(config, boot.error)) { return } @@ -104,7 +128,7 @@ export function BootFailureOverlay() { return () => { cancelled = true } - }, [visible]) + }, [boot.error, visible]) if (!visible || suppressed) { return null @@ -124,16 +148,17 @@ export function BootFailureOverlay() { const switchToLocalGateway = async () => { setBusy('local') - // applyConnectionConfig reloads the window from the main process. + // Soft apply: tears down the primary and re-dials in place (shell stays). await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' }).catch(() => undefined) setBusy(null) } - // Open the gateway's login window (renders the username/password form for a - // basic gateway, or the OAuth redirect otherwise — the desktop drives both - // through the same window). On a successful sign-in the session cookie is - // re-established in the persistent partition; reload so boot re-runs and the - // reconnect now mints a ticket against a live session. + // Clear the OAuth partition first, then open the gateway's login window + // (username/password form or OAuth redirect — the desktop drives both). A + // partition-wide sign-out drops stale gateway AND identity-provider cookies so + // an expired session can't silently bounce us back into the same state. On a + // successful sign-in the cookie is re-established; reload so boot mints a fresh + // ticket against a live session. const signInRemote = async () => { if (!remoteReauth) { return @@ -142,6 +167,7 @@ export function BootFailureOverlay() { setBusy('signin') try { + await window.hermesDesktop?.oauthLogoutConnectionConfig?.() const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url) if (result?.connected) { @@ -172,6 +198,92 @@ export function BootFailureOverlay() { withProvider: copy.signInWithProvider }) + // Recovery actions are shaped by the failure kind so the leading (primary) + // button is the one that actually fixes it: Sign in for a lapsed remote + // session, Connection settings for any other remote failure (local Retry / + // Repair can't revive a dead remote — Repair is dropped there), Retry for a + // local backend. Open logs is always appended. + type RecoveryVariant = ComponentProps<typeof Button>['variant'] + interface RecoveryAction { + key: string + label: string + onClick: () => void + icon?: ReactNode + variant?: RecoveryVariant + busy?: Exclude<BusyAction, null> + } + + const settingsAction: RecoveryAction = { + key: 'settings', + label: copy.gatewaySettings, + onClick: () => setView('connect'), + icon: <SlidersHorizontal /> + } + + const retryAction: RecoveryAction = { + key: 'retry', + label: copy.retry, + onClick: () => void retry(), + icon: <RefreshCw />, + busy: 'retry' + } + + const localAction: RecoveryAction = { + key: 'local', + label: copy.useLocalGateway, + onClick: () => void switchToLocalGateway(), + variant: 'secondary', + busy: 'local' + } + + let actions: RecoveryAction[] + let hint: string + + if (remoteReauth) { + actions = [ + { key: 'signin', label: copy.signOutAndSignIn, onClick: () => void signInRemote(), icon: <LogIn />, busy: 'signin' }, + { ...settingsAction, variant: 'secondary' }, + localAction + ] + hint = copy.remoteSignInHint(label) + } else if (remoteFailure) { + actions = [settingsAction, { ...retryAction, variant: 'secondary' }, localAction] + hint = copy.remoteFailureHint + } else { + // Local failure: Use-local is redundant with Retry (both re-target local), so + // it's dropped here; keep it for remote failures where it's the fall-back. + actions = [ + retryAction, + { key: 'repair', label: copy.repairInstall, onClick: () => void repair(), icon: <Wrench />, variant: 'secondary', busy: 'repair' }, + { ...settingsAction, variant: 'ghost' } + ] + hint = copy.repairHint + } + + if (view === 'connect') { + return ( + <div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6"> + <div className="flex max-h-[86vh] w-full max-w-[46rem] flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous"> + {/* Subtle back affordance (projects/overlay idiom): muted → foreground + on hover, no divider. */} + <button + className="flex w-full items-center gap-1.5 px-4 pt-4 text-left text-xs text-muted-foreground transition-colors hover:text-foreground" + onClick={() => setView('recovery')} + type="button" + > + <ChevronLeft className="size-3.5" /> + {copy.back} + </button> + <div className="min-h-0 flex-1 pt-4"> + <Suspense fallback={<Loader className="mx-auto my-16 size-6 text-(--ui-text-tertiary)" />}> + <GatewaySettings embedded /> + </Suspense> + </div> + </div> + </div> + ) + } + return ( <div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6"> <div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous"> @@ -187,40 +299,25 @@ export function BootFailureOverlay() { </div> </div> - <div className="grid gap-4 p-5"> + <div className="grid gap-4 p-5 pt-0"> <div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive"> {boot.error} </div> <div className="grid gap-2"> <div className="flex flex-wrap gap-2"> - {remoteReauth ? ( - <Button disabled={Boolean(busy)} onClick={() => void signInRemote()}> - {busy === 'signin' ? <Loader2 className="animate-spin" /> : <LogIn />} - {label} + {actions.map(action => ( + <Button disabled={Boolean(busy)} key={action.key} onClick={action.onClick} variant={action.variant}> + {action.busy && busy === action.busy ? <Loader2 className="animate-spin" /> : action.icon} + {action.label} </Button> - ) : ( - <Button disabled={Boolean(busy)} onClick={() => void retry()}> - {busy === 'retry' ? <Loader2 className="animate-spin" /> : <RefreshCw />} - {copy.retry} - </Button> - )} - {!remoteReauth ? ( - <Button disabled={Boolean(busy)} onClick={() => void repair()} variant="secondary"> - {busy === 'repair' ? <Loader2 className="animate-spin" /> : <Wrench />} - {copy.repairInstall} - </Button> - ) : null} - <Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="secondary"> - {busy === 'local' ? <Loader2 className="animate-spin" /> : null} - {copy.useLocalGateway} - </Button> + ))} <Button onClick={openLogs} variant="ghost"> <FileText /> {copy.openLogs} </Button> </div> - <p className="text-xs text-muted-foreground">{remoteReauth ? copy.remoteSignInHint : copy.repairHint}</p> + <p className="text-xs text-muted-foreground">{hint}</p> </div> {logs.length > 0 ? ( diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts index 613b43f65353..5d198c96e416 100644 --- a/apps/desktop/src/components/boot-failure-reauth.test.ts +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest' import type { DesktopConnectionConfig } from '@/global' -import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth' +import { + deriveProviderShape, + isRemoteConfig, + isRemoteReauthError, + isRemoteReauthFailure, + signInLabel +} from './boot-failure-reauth' function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnectionConfig { return { @@ -14,23 +20,53 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec remoteTokenPreview: null, remoteTokenSet: false, remoteUrl: 'https://box:9119', + cloudOrg: '', ...overrides } } +describe('isRemoteConfig', () => { + it('true for remote/cloud with a URL, regardless of auth mode or connection', () => { + expect(isRemoteConfig(config({ remoteAuthMode: 'token', remoteOauthConnected: false }))).toBe(true) + expect(isRemoteConfig(config({ mode: 'cloud', remoteOauthConnected: true }))).toBe(true) + }) + + it('false for local, for a remote with no URL, and for nullish', () => { + expect(isRemoteConfig(config({ mode: 'local' }))).toBe(false) + expect(isRemoteConfig(config({ remoteUrl: '' }))).toBe(false) + expect(isRemoteConfig(null)).toBe(false) + }) +}) + describe('isRemoteReauthFailure', () => { it('true for a remote, gated, disconnected gateway with a URL', () => { expect(isRemoteReauthFailure(config())).toBe(true) }) - it('false when the oauth session is still connected', () => { - expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }))).toBe(false) + it('false when connected and the boot error is not auth-shaped', () => { + expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }), 'Python exploded')).toBe(false) + }) + + it('true when the indicator reads connected but the boot error is auth-shaped (expired session)', () => { + expect( + isRemoteReauthFailure(config({ remoteOauthConnected: true }), 'Your remote gateway session has expired.') + ).toBe(true) }) it('false for a local gateway', () => { expect(isRemoteReauthFailure(config({ mode: 'local' }))).toBe(false) }) + it('true for a cloud connection with a lapsed session (cloud resolves to remote oauth)', () => { + // A 'cloud' connection is a remote oauth backend under the hood (Q6), so a + // lapsed cloud session is the same reauth failure as a lapsed remote one. + expect(isRemoteReauthFailure(config({ mode: 'cloud' }))).toBe(true) + }) + + it('false for a connected cloud session', () => { + expect(isRemoteReauthFailure(config({ mode: 'cloud', remoteOauthConnected: true }))).toBe(false) + }) + it('false for a token (non-gated) remote gateway', () => { expect(isRemoteReauthFailure(config({ remoteAuthMode: 'token' }))).toBe(false) }) @@ -45,6 +81,18 @@ describe('isRemoteReauthFailure', () => { }) }) +describe('isRemoteReauthError', () => { + it('recognizes auth-shaped boot errors', () => { + expect(isRemoteReauthError('Your remote gateway session has expired.')).toBe(true) + expect(isRemoteReauthError('OAuth: please sign in')).toBe(true) + }) + + it('ignores non-auth boot errors and nullish', () => { + expect(isRemoteReauthError('Hermes background process exited during startup.')).toBe(false) + expect(isRemoteReauthError(null)).toBe(false) + }) +}) + describe('deriveProviderShape', () => { it('generic copy when there are no providers', () => { expect(deriveProviderShape([])).toEqual({ isPassword: false, providerLabel: 'your identity provider' }) diff --git a/apps/desktop/src/components/boot-failure-reauth.ts b/apps/desktop/src/components/boot-failure-reauth.ts index 3aeae7846e4c..63b805faccbe 100644 --- a/apps/desktop/src/components/boot-failure-reauth.ts +++ b/apps/desktop/src/components/boot-failure-reauth.ts @@ -26,22 +26,40 @@ const DEFAULT_SIGN_IN_COPY: SignInCopy = { withProvider: provider => `Sign in with ${provider}` } -// A remote, gated (oauth-bucket), not-currently-connected gateway is a -// remote-reauth boot failure: the access cookie lapsed (e.g. the remote -// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't -// fix it — only re-establishing the remote session can. A connected oauth -// session, or a token/local gateway, boots for some other reason the -// local-recovery buttons address, so those return false here. -export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean { - if (!config) { - return false - } +// True when the app is pointed at a remote/cloud backend (either resolves to a +// remote URL). Any boot failure in this shape is fixable from Settings → +// Gateway (edit URL / token / sign in) — the local Retry/Repair buttons target +// the bundled backend and can't help. Drives the escape-hatch emphasis. +export function isRemoteConfig(config: DesktopConnectionConfig | null | undefined): boolean { + return Boolean(config && (config.mode === 'remote' || config.mode === 'cloud') && config.remoteUrl) +} + +// True when a boot error is auth-shaped — the refresh token was rejected or the +// remote couldn't mint a websocket ticket. The Settings indicator can still read +// "connected" (a stale RT cookie exists), so the error text is part of the +// signal; without it a connected-but-expired session drops into the local-only +// recovery buttons for a problem only reauth can fix. +export function isRemoteReauthError(error: string | null | undefined): boolean { + const text = String(error || '').toLowerCase() return ( - config.mode === 'remote' && - config.remoteAuthMode === 'oauth' && - !config.remoteOauthConnected && - Boolean(config.remoteUrl) + text.includes('remote gateway session has expired') || + text.includes('gateway sign-in required') || + text.includes('needs oauth login') || + (text.includes('oauth') && (text.includes('not signed in') || text.includes('sign in'))) + ) +} + +// A remote, gated (oauth-bucket) gateway is a remote-reauth boot failure when the +// session isn't connected OR the boot error is auth-shaped (connected-but-expired +// — see isRemoteReauthError). Only re-establishing the remote session fixes it; +// the local Retry/Repair buttons can't. 'cloud' counts as remote (it resolves to +// a remote oauth backend), so a lapsed cloud session is the same failure. +export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined, error?: string | null): boolean { + return ( + isRemoteConfig(config) && + config!.remoteAuthMode === 'oauth' && + (!config!.remoteOauthConnected || isRemoteReauthError(error)) ) } diff --git a/apps/desktop/src/components/chat/vibe-hearts.tsx b/apps/desktop/src/components/chat/vibe-hearts.tsx new file mode 100644 index 000000000000..b4f5e7e1a244 --- /dev/null +++ b/apps/desktop/src/components/chat/vibe-hearts.tsx @@ -0,0 +1,119 @@ +import { type CSSProperties } from 'react' + +import { + createParticleEmitter, + ParticleField, + type ParticleFieldConfig +} from '@/components/particles/particle-field' +import { $petActive, flashPetActivity } from '@/store/pet' +import { $petOverlayActive, forwardPetReaction } from '@/store/pet-overlay' + +/** + * TikTok-style floating hearts — a thin skin over {@link ParticleField} (pixel + * heart glyph + pink). Placed two ways: rising from the composer when no pet is + * out, or from the pet when one is. Fired by the core `reaction` event (affection + * in a user message) via {@link burstVibeHearts}. + */ + +// Light pink reads on both light and dark chat surfaces. +const HEART_COLORS = ['#ff9ec4'] as const + +/** Composer placement: hearts rise the thread height (rise = % of the tall lane). */ +export const COMPOSER_HEART_CONFIG: Partial<ParticleFieldConfig> = { + count: 12, + size: [6, 13], + rise: [6.75, 15.75], + duration: [320, 700] +} + +/** Pet placement: a compact puff off the pet. The field box spans feet→head, so + * rise ≥100% carries hearts from the feet to ~10-20% above the pet before fading. */ +const PET_HEART_CONFIG: Partial<ParticleFieldConfig> = { + count: 10, + spawnWindowMs: 450, + size: [6, 12], + rise: [98, 118], + duration: [480, 880], + swayAmp: [5, 14], + bank: [6, 14] +} + +// Pixel-art heart from @nous-research/ui (14×12), crisp + `currentColor`. +const HEART_GLYPH = ( + <svg fill="none" shapeRendering="crispEdges" viewBox="0 0 14 12" xmlns="http://www.w3.org/2000/svg"> + <path + d="M13.2 0v5.65714h-1.8857v1.88572H9.42857v1.88571H7.54286v1.88573H5.65714V9.42857H3.77143V7.54286H1.88571V5.65714H0V0h5.65714v1.88571h1.88572V0z" + fill="currentColor" + /> + </svg> +) + +const emitter = createParticleEmitter() + +/** Play hearts in THIS window (whichever HeartField is mounted). The overlay + * window calls this directly off the mirrored vibe signal. */ +export const playVibeHearts = (count?: number) => emitter.burst(count) + +/** + * Fire a vibe burst (from the core `reaction` event). Routes to where the + * affection should land: + * - pet popped out → forward to the overlay window + celebrate (mirrored) + * - pet in-window → play here (on the pet) + celebrate + * - no pet → play here (composer) + */ +export const burstVibeHearts = (count?: number) => { + const overlay = $petOverlayActive.get() + + if (overlay || $petActive.get()) { + flashPetActivity({ celebrate: true }) + } + + if (overlay) { + forwardPetReaction('vibe') + } else { + playVibeHearts(count) + } +} + +export interface HeartFieldProps { + config?: Partial<ParticleFieldConfig> + className?: string + style?: CSSProperties +} + +/** Heart-skinned particle field. Caller supplies placement + a config preset. */ +export function HeartField({ config, className, style }: HeartFieldProps) { + return ( + <ParticleField + className={className} + colors={HEART_COLORS} + config={config} + emitter={emitter} + glyph={HEART_GLYPH} + style={style} + /> + ) +} + +/** + * Pet-anchored hearts, feet→~10-20% above. One place owns the geometry so the + * in-window pet and the popped-out overlay stay identical. `petW`/`petH` are the + * rendered sprite dimensions (frame × scale). + */ +export function PetHeartField({ petW, petH }: { petW: number; petH: number }) { + return ( + <HeartField + config={PET_HEART_CONFIG} + style={{ + bottom: 0, + height: Math.max(96, petH), + left: '50%', + pointerEvents: 'none', + position: 'absolute', + transform: 'translateX(-50%)', + width: Math.max(90, petW * 1.5), + zIndex: 2 + }} + /> + ) +} diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index 7bcbc4bf84b9..f15bd79cdc2d 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -22,7 +22,7 @@ import { cn } from '@/lib/utils' * DesktopInstallOverlay * * Renders the first-launch install progress for Hermes Agent. Mounted always; - * shows itself only when main.cjs reports an in-flight bootstrap (state.active) + * shows itself only when main.ts reports an in-flight bootstrap (state.active) * OR an error from a completed-failed bootstrap (state.error). When the * bootstrap finishes successfully the overlay fades out and the rest of the * app (existing onboarding overlay -> main UI) takes over. @@ -32,7 +32,7 @@ import { cn } from '@/lib/utils' * - onBootstrapEvent(callback) -- live event stream * * The reducer is intentionally simple: every event mutates an in-component - * snapshot the same way main.cjs mutates its server-side snapshot. We don't + * snapshot the same way main.ts mutates its server-side snapshot. We don't * try to reconcile -- if we miss an event (shouldn't happen) the initial * getBootstrapState() call will resync the picture on the next render. * @@ -559,7 +559,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP </Button> <Button onClick={async () => { - // Tell main.cjs to clear its latched failure BEFORE we + // Tell main.ts to clear its latched failure BEFORE we // reload. Otherwise the renderer reload calls getConnection // and main short-circuits to the latched error without // re-running install.ps1. diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx index e5e493159853..508dfb27f335 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx @@ -1,7 +1,8 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { $desktopBoot } from '@/store/boot' +import { $gatewaySwitching } from '@/store/gateway-switch' import { $desktopOnboarding } from '@/store/onboarding' import { setGatewayState } from '@/store/session' @@ -23,6 +24,7 @@ import { GatewayConnectingOverlay } from './gateway-connecting-overlay' function resetStores() { setGatewayState('idle') + $gatewaySwitching.set(false) $desktopBoot.set({ error: null, fakeMode: false, @@ -59,7 +61,7 @@ const isRecoveryShown = () => Boolean(screen.queryByText(/use local gateway/i) || screen.queryByText(/retry/i) || screen.queryByText(/sign in/i)) describe('connecting overlay vs recovery surface', () => { - it('hard initial-boot failure surfaces the recovery overlay (the working path)', () => { + it('hard initial-boot failure surfaces the recovery overlay (the working path)', async () => { // failDesktopBoot() ran: error set, gateway never opened. $desktopBoot.set({ ...$desktopBoot.get(), @@ -69,41 +71,50 @@ describe('connecting overlay vs recovery surface', () => { }) setGatewayState('error') - render( - <> - <GatewayConnectingOverlay /> - <BootFailureOverlay /> - </> - ) + await act(async () => { + render( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + }) expect(isRecoveryShown()).toBe(true) // Connecting overlay bows out when boot.error is set. expect(isConnectingShown()).toBe(false) }) - it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', () => { + it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', async () => { // 1. Initial boot succeeded: gateway opened, boot completed (no error). setGatewayState('open') - const { rerender } = render( - <> - <GatewayConnectingOverlay /> - <BootFailureOverlay /> - </> - ) + let rerender!: (ui: React.ReactElement) => void + await act(async () => { + const result = render( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + + rerender = result.rerender + }) expect(isConnectingShown()).toBe(false) // 2. The remote VPS socket drops (sleep/wake, remote restart, network). // bootCompleted is true, so useGatewayBoot routes this through // scheduleReconnect() — boot.error stays NULL. - setGatewayState('closed') - rerender( - <> - <GatewayConnectingOverlay /> - <BootFailureOverlay /> - </> - ) + await act(async () => { + setGatewayState('closed') + rerender!( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + }) // The initial-boot connecting overlay stays out of the way, so settings and // the composer remain reachable during the reconnect loop. @@ -113,19 +124,53 @@ describe('connecting overlay vs recovery surface', () => { // 3. Reconnect loops against the dead remote: gatewayState bounces closed // → error → closed. Until the escalation path sets boot.error, the app // remains usable instead of modal-blocked. - setGatewayState('error') - rerender( - <> - <GatewayConnectingOverlay /> - <BootFailureOverlay /> - </> - ) + await act(async () => { + setGatewayState('error') + rerender!( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + }) expect($desktopBoot.get().error).toBeNull() expect(isConnectingShown()).toBe(false) expect(isRecoveryShown()).toBe(false) }) - it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', () => { + it('soft gateway switch keeps the shell — no fullscreen CONNECTING', async () => { + setGatewayState('open') + + const { rerender } = render( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + + await act(async () => { + $gatewaySwitching.set(true) + $desktopBoot.set({ + ...$desktopBoot.get(), + running: true, + visible: true, + progress: 4, + error: null + }) + setGatewayState('closed') + rerender( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + }) + + expect(isConnectingShown()).toBe(false) + expect(isRecoveryShown()).toBe(false) + }) + + it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', async () => { // Mirrors what useGatewayBoot.scheduleReconnect() now does after ~45s of // failed post-boot reconnects: it calls failDesktopBoot(), flipping the UI // from the dead-end CONNECTING overlay to the recovery surface. @@ -137,16 +182,18 @@ describe('connecting overlay vs recovery surface', () => { visible: true }) - render( - <> - <GatewayConnectingOverlay /> - <BootFailureOverlay /> - </> - ) + await act(async () => { + render( + <> + <GatewayConnectingOverlay /> + <BootFailureOverlay /> + </> + ) + }) // Escape hatch is now reachable; the connecting overlay bows out. expect(isRecoveryShown()).toBe(true) - expect(screen.getByText(/use local gateway/i)).toBeTruthy() + expect(screen.getByRole('button', { name: /gateway settings/i })).toBeTruthy() expect(isConnectingShown()).toBe(false) }) }) diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index bff722b9a28f..be4b5d82843f 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -1,19 +1,15 @@ import { useStore } from '@nanostores/react' import { useEffect, useRef, useState } from 'react' +import { DecodeText } from '@/components/ui/decode-text' import { cn } from '@/lib/utils' import { $desktopBoot } from '@/store/boot' +import { $gatewaySwitching } from '@/store/gateway-switch' import { $gatewayState } from '@/store/session' -// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at -// the render level means no timer logic (even a stale HMR one) can ever -// scramble "CONN". -const PREFIX = 'CONN' -const TAIL = 'ECTING' -// Even-weight mono ascii so cycling glyphs don't jump width (matches the -// nousnet-web download-button decode effect). -const SCRAMBLE_CHARS = '/\\|-_=+<>~:*' -const TICK_MS = 45 +// Decode mechanics live in the shared <DecodeText> primitive +// (components/ui/decode-text.tsx). "CONN" stays legible via prefix={4}. +const TEXT = 'CONNECTING' // Exit choreography (ms): text fades down + out, hold, then the overlay fades. const TEXT_OUT_MS = 360 @@ -39,18 +35,19 @@ function forcedPreview(): boolean { } } -function scrambledTail(resolvedCount: number): string { - return Array.from(TAIL, (ch, i) => - i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0] - ).join('') -} - export function GatewayConnectingOverlay() { const gatewayState = useStore($gatewayState) const boot = useStore($desktopBoot) + const gatewaySwitching = useStore($gatewaySwitching) const [previewing] = useState(forcedPreview) - const [tail, setTail] = useState(TAIL) const [phase, setPhase] = useState<Phase>('live') + // Once cold boot has completed once, never resurrect the fullscreen overlay + // — soft gateway switches keep the shell and reskeleton the sidebar instead. + const coldBootDoneRef = useRef(false) + + if (!boot.running && boot.progress >= 100 && !boot.error) { + coldBootDoneRef.current = true + } // The full-screen connecting overlay is for initial boot only. After a // healthy boot, flaky networks / sleep-wake can drop the socket and flip the @@ -58,7 +55,14 @@ export function GatewayConnectingOverlay() { // the chat then — users should still be able to type drafts, open settings, // and recover instead of staring at a modal CONNECTING screen. const initialBootActive = boot.visible || boot.running || boot.progress < 100 - const connecting = gatewayState !== 'open' && !boot.error && initialBootActive + + const connecting = + !coldBootDoneRef.current && + !gatewaySwitching && + gatewayState !== 'open' && + !boot.error && + initialBootActive + // Latches once we've actually shown the overlay, so the brief frame where // gatewayState flips to "open" (connecting -> false) before the exit phase // kicks in doesn't unmount us and cause a flash. @@ -68,36 +72,6 @@ export function GatewayConnectingOverlay() { shownRef.current = true } - // Decode loop — only while live (freeze the resolved word during the exit). - useEffect(() => { - if (phase !== 'live' || (!previewing && !connecting)) { - return - } - - let resolved = 0 - let hold = 0 - - const id = window.setInterval(() => { - if (resolved >= TAIL.length) { - hold += 1 - - if (hold > 16) { - resolved = 0 - hold = 0 - } - - setTail(TAIL) - - return - } - - resolved += 0.5 - setTail(scrambledTail(Math.floor(resolved))) - }, TICK_MS) - - return () => window.clearInterval(id) - }, [phase, previewing, connecting]) - // Kick off the exit when connected: real connect, or a faked timer in preview. useEffect(() => { if (phase !== 'live') { @@ -105,16 +79,12 @@ export function GatewayConnectingOverlay() { } if (previewing) { - const id = window.setTimeout(() => { - setTail(TAIL) - setPhase('text-out') - }, PREVIEW_CONNECT_MS) + const id = window.setTimeout(() => setPhase('text-out'), PREVIEW_CONNECT_MS) return () => window.clearTimeout(id) } if (gatewayState === 'open' && shownRef.current) { - setTail(TAIL) setPhase('text-out') } }, [phase, previewing, gatewayState]) @@ -135,10 +105,7 @@ export function GatewayConnectingOverlay() { // Preview replays so we can keep watching the transition. if (phase === 'gone' && previewing) { - const id = window.setTimeout(() => { - setTail(TAIL) - setPhase('live') - }, PREVIEW_REPLAY_MS) + const id = window.setTimeout(() => setPhase('live'), PREVIEW_REPLAY_MS) return () => window.clearTimeout(id) } @@ -169,21 +136,16 @@ export function GatewayConnectingOverlay() { overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100' )} > - <style>{'@keyframes gco-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style> - <span + <DecodeText + active={phase === 'live' && (previewing || connecting)} className={cn( - 'inline-flex items-center pl-[0.4em] font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums text-(--theme-primary) transition duration-300 ease-out', + 'pl-[0.4em] text-(--theme-primary) transition duration-300 ease-out', leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100' )} - > - {PREFIX} - {tail} - <span - aria-hidden="true" - className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]" - style={{ animation: 'gco-cursor 1s step-end infinite' }} - /> - </span> + cursor + prefix={4} + text={TEXT} + /> </div> ) } diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index 0b81db3775b7..c7541b924d29 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -30,7 +30,13 @@ import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes' import { DocsLink, FlowPanel, Status } from './flow' import { FeaturedProviderRow, KeyProviderRow, ProviderRow, sortProviders } from './providers' -export { FeaturedProviderRow, KeyProviderRow, ProviderRow, providerTitle, sortProviders } from './providers' +export { + FeaturedProviderRow, + KeyProviderRow, + ProviderRow, + providerTitle, + sortProviders +} from './providers' interface DesktopOnboardingOverlayProps { enabled: boolean @@ -55,6 +61,12 @@ const API_KEY_OPTIONS: ApiKeyOption[] = [ envKey: 'OPENROUTER_API_KEY', docsUrl: 'https://openrouter.ai/keys' }, + { + id: 'fireworks', + name: 'Fireworks AI', + envKey: 'FIREWORKS_API_KEY', + docsUrl: 'https://app.fireworks.ai/settings/users/api-keys' + }, { id: 'openai', name: 'OpenAI', @@ -395,6 +407,15 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { const { t } = useI18n() const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding) const [showAll, setShowAll] = useState(readShowAll) + // Which key-form option to preselect when we flip to 'apikey' mode. The + // OpenRouter row selects its key; the generic link lands on the first option. + const [apiKeyInitialEnv, setApiKeyInitialEnv] = useState<string | undefined>(undefined) + + const openKeyForm = (envKey?: string) => { + setApiKeyInitialEnv(envKey) + setOnboardingMode('apikey') + } + const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers]) const hasOauth = ordered.length > 0 const apiKeyOptions = useApiKeyCatalog() @@ -408,7 +429,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { <div className="grid gap-3"> <ApiKeyForm canGoBack={hasOauth && !localEndpoint} - initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : undefined} + initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : apiKeyInitialEnv} onBack={() => setOnboardingMode('oauth')} onSave={(envKey, value, name, apiKey) => saveOnboardingApiKey(envKey, value, name, ctx, apiKey)} options={apiKeyOptions} @@ -443,7 +464,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { {rest.map(p => ( <ProviderRow key={p.id} onSelect={select} provider={p} /> ))} - <KeyProviderRow onClick={() => setOnboardingMode('apikey')} /> + <KeyProviderRow onClick={() => openKeyForm('OPENROUTER_API_KEY')} /> </> ) : null} </div> @@ -466,7 +487,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { {manual ? <span /> : <ChooseLaterLink />} <Button className="-mr-2 font-medium" - onClick={() => setOnboardingMode('apikey')} + onClick={() => openKeyForm()} size="xs" type="button" variant="text" diff --git a/apps/desktop/src/components/pane-shell/context.ts b/apps/desktop/src/components/pane-shell/context.ts deleted file mode 100644 index b5bb3a907ace..000000000000 --- a/apps/desktop/src/components/pane-shell/context.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createContext } from 'react' - -export interface PaneSlot { - side: 'left' | 'right' - open: boolean - /** Resolved CSS `grid-column` value (e.g. "3 / 4", or a full-side span for a bottom-row pane). */ - gridColumn: string - /** Resolved CSS `grid-row` value ("1 / -1" full-height, "1 / 2" above a bottom row, "2 / 3" the row itself). */ - gridRow: string - /** True when this pane lays out as a horizontal row beneath its rail instead of a vertical column. */ - bottomRow: boolean -} - -export interface PaneShellContextValue { - paneById: Map<string, PaneSlot> - mainColumn: number -} - -export const PaneShellContext = createContext<PaneShellContextValue | null>(null) diff --git a/apps/desktop/src/components/pane-shell/edit-mode.tsx b/apps/desktop/src/components/pane-shell/edit-mode.tsx new file mode 100644 index 000000000000..1e7bba6a64a7 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/edit-mode.tsx @@ -0,0 +1,59 @@ +/** + * Layout edit mode — the shared toggle for the tree renderer's FancyZones-style + * rearrangement (see tree/renderer.tsx). The toggle hotkey is a `keybinds` + * contribution (`layout.editMode`, default ⌘⇧\ — the sibling of ⌘\ = flip + * panes), so it's rebindable and collision-checked like every other action. + * This hook only owns Escape-to-exit. + */ + +import { atom } from 'nanostores' +import { useEffect } from 'react' + +import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' + +export const $layoutEditMode = atom(false) + +export function toggleLayoutEditMode() { + $layoutEditMode.set(!$layoutEditMode.get()) +} + +/** Escape exits edit mode. Registered once by the layout root. */ +export function useLayoutEditHotkey(enabled: boolean) { + useEffect(() => { + if (!enabled || typeof window === 'undefined') { + return + } + + // Own an Escape layer only WHILE edit mode is on, so it doesn't outrank the + // narrow-pane reveal the rest of the time. + let releaseLayer: (() => void) | null = null + + const unsub = $layoutEditMode.subscribe(on => { + if (on && !releaseLayer) { + releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.layoutEdit) + } else if (!on && releaseLayer) { + releaseLayer() + releaseLayer = null + } + }) + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape' || e.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)) { + return + } + + if ($layoutEditMode.get()) { + e.preventDefault() + $layoutEditMode.set(false) + } + } + + window.addEventListener('keydown', onKeyDown) + + return () => { + window.removeEventListener('keydown', onKeyDown) + unsub() + releaseLayer?.() + } + }, [enabled]) +} diff --git a/apps/desktop/src/components/pane-shell/geometry.ts b/apps/desktop/src/components/pane-shell/geometry.ts new file mode 100644 index 000000000000..bfc05d35c0f0 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/geometry.ts @@ -0,0 +1,270 @@ +/** + * Pane geometry — AABB INTERSECTION → WINDOW-CONTROL AWARENESS. The native + * window controls (macOS traffic lights top-left / Windows-WSLg overlay + * top-right) are a rectangle in viewport pixels. Any region whose rect + * intersects it must reserve that space and expose a drag strip. One + * `intersect()` call replaces per-layout inset special cases. + * + * Also publishes the WORKSPACE zone's edges as CSS vars (see + * publishWorkspaceGeometry) — chrome that aligns to the main pane (titlebar + * title et al) consumes `var(--workspace-left/right)` in plain CSS instead of + * threading rects through React. + */ + +import { type RefObject, useLayoutEffect, useState, useSyncExternalStore } from 'react' + +import { $layoutTree } from '@/components/pane-shell/tree/store' +import { $connection } from '@/store/session' + +// --------------------------------------------------------------------------- +// Rects +// --------------------------------------------------------------------------- + +export interface Rect { + x: number + y: number + width: number + height: number +} + +/** AABB intersection. Returns null when the rects don't overlap. */ +export function intersect(a: Rect, b: Rect): Rect | null { + const x = Math.max(a.x, b.x) + const y = Math.max(a.y, b.y) + const right = Math.min(a.x + a.width, b.x + b.width) + const bottom = Math.min(a.y + a.height, b.y + b.height) + + if (right <= x || bottom <= y) { + return null + } + + return { x, y, width: right - x, height: bottom - y } +} + +// --------------------------------------------------------------------------- +// Native window controls rect +// --------------------------------------------------------------------------- + +/** Height of the band the native controls live in. */ +const CONTROLS_BAND_HEIGHT = 34 +/** Width of the macOS traffic-light cluster measured from the buttons' x. */ +const MACOS_LIGHTS_WIDTH = 58 +const MACOS_FALLBACK_BUTTON_X = 24 + +interface ConnectionLike { + windowButtonPosition?: { x: number; y: number } | null + nativeOverlayWidth?: number | null + isFullscreen?: boolean | null +} + +/** + * The native window-control rectangle in viewport pixels, or null when there + * is nothing to dodge (fullscreen, plain browser, secondary windows with + * hidden controls). + */ +export function windowControlsRect(connection: ConnectionLike | null, viewportWidth: number): Rect | null { + const inElectron = typeof window !== 'undefined' && 'hermesDesktop' in window + + if (!inElectron) { + return null + } + + if (connection?.isFullscreen) { + return null + } + + // Windows / WSLg: native overlay on the top-right. + const overlayWidth = connection?.nativeOverlayWidth ?? 0 + + if (overlayWidth > 0) { + return { x: viewportWidth - overlayWidth, y: 0, width: overlayWidth, height: CONTROLS_BAND_HEIGHT } + } + + // macOS: traffic lights on the top-left. windowButtonPosition === null means + // the platform has no left-side controls at all (Windows/Linux w/o overlay). + const pos = connection?.windowButtonPosition + + if (pos === null) { + return null + } + + const isMac = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform) + + if (!pos && !isMac) { + return null + } + + const x = pos?.x ?? MACOS_FALLBACK_BUTTON_X + + return { x: 0, y: 0, width: x + MACOS_LIGHTS_WIDTH, height: CONTROLS_BAND_HEIGHT } +} + +// --------------------------------------------------------------------------- +// Live hook +// --------------------------------------------------------------------------- + +let cachedRect: Rect | null = null +let cachedKey = '' + +function rectKey(r: Rect | null) { + return r ? `${r.x},${r.y},${r.width},${r.height}` : '' +} + +function readControlsRect(): Rect | null { + const next = windowControlsRect($connection.get(), typeof window === 'undefined' ? 0 : window.innerWidth) + const key = rectKey(next) + + // Referentially stable snapshot for useSyncExternalStore. + if (key !== cachedKey) { + cachedKey = key + cachedRect = next + } + + return cachedRect +} + +function subscribeControlsRect(cb: () => void) { + const unsubConnection = $connection.subscribe(() => cb()) + window.addEventListener('resize', cb) + + return () => { + unsubConnection() + window.removeEventListener('resize', cb) + } +} + +/** Reactive native window-controls rect (connection + viewport aware). */ +export function useWindowControlsRect(): Rect | null { + return useSyncExternalStore(subscribeControlsRect, readControlsRect, () => null) +} + +// --------------------------------------------------------------------------- +// Per-element overlap +// --------------------------------------------------------------------------- + +function sameRect(a: Rect | null, b: Rect | null) { + if (a === b) {return true} + + if (!a || !b) {return false} + + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height +} + +// --------------------------------------------------------------------------- +// Workspace-edge CSS vars +// --------------------------------------------------------------------------- + +/** + * Publish the workspace zone's viewport edges as root CSS vars: + * --workspace-left : px from the viewport's left to the main zone + * --workspace-right : px from the main zone's right to the viewport's right + * + * Measured once per relevant change (zone resize via ResizeObserver, layout + * mutations via $layoutTree, window resize) and consumed in plain CSS — the + * measured-var idiom (--composer-measured-height), not per-component JS + * geometry. Call once from the tree root; returns the disposer. + */ +export function publishWorkspaceGeometry(): () => void { + const root = document.documentElement + let el: HTMLElement | null = null + let lastLeft = NaN + let lastRight = NaN + + const ro = new ResizeObserver(() => measure()) + + const measure = () => { + const next = document.querySelector<HTMLElement>('[data-session-anchor="workspace"]') + + if (next !== el) { + if (el) { + ro.unobserve(el) + } + + el = next + + if (el) { + ro.observe(el) + } + } + + if (!el) { + return + } + + const r = el.getBoundingClientRect() + const left = Math.round(r.left) + const right = Math.round(window.innerWidth - r.right) + + // Skip unchanged writes: a sash drag fires the RO every frame, and each + // :root custom-property set dirties style for everything that reads them. + if (left !== lastLeft) { + lastLeft = left + root.style.setProperty('--workspace-left', `${left}px`) + } + + if (right !== lastRight) { + lastRight = right + root.style.setProperty('--workspace-right', `${right}px`) + } + } + + // Tree mutations move zones without resizing them (⌘\ flip) — re-measure a + // frame later, after the DOM committed. RO covers width changes (sash drags, + // side collapses); window resize covers the rest. + const unsubTree = $layoutTree.listen(() => requestAnimationFrame(measure)) + window.addEventListener('resize', measure) + measure() + + return () => { + unsubTree() + window.removeEventListener('resize', measure) + ro.disconnect() + root.style.removeProperty('--workspace-left') + root.style.removeProperty('--workspace-right') + } +} + +/** + * Intersects an element's live viewport rect with the native window-controls + * rect. Returns the overlap in ELEMENT-LOCAL coordinates (null when clear), so + * the consumer can reserve the space and paint a drag strip without knowing + * anything about platform, fullscreen state, or layout position. + */ +export function useWindowControlsOverlap(ref: RefObject<HTMLElement | null>, enabled = true): Rect | null { + const controls = useWindowControlsRect() + const [overlap, setOverlap] = useState<Rect | null>(null) + + useLayoutEffect(() => { + const el = ref.current + + if (!enabled || !controls || !el) { + setOverlap(null) + + return + } + + const update = () => { + const r = el.getBoundingClientRect() + const hit = intersect(controls, { x: r.x, y: r.y, width: r.width, height: r.height }) + const local = hit ? { x: hit.x - r.x, y: hit.y - r.y, width: hit.width, height: hit.height } : null + + setOverlap(prev => (sameRect(prev, local) ? prev : local)) + } + + update() + + // Size changes fire the observer; cross-window moves fire `resize`. A pane + // shifted only by a sibling's resize re-measures on its own grid reflow + // (its track width changes), so this covers the shell's real cases. + const ro = new ResizeObserver(update) + ro.observe(el) + window.addEventListener('resize', update) + + return () => { + ro.disconnect() + window.removeEventListener('resize', update) + } + }, [controls, enabled, ref]) + + return overlap +} diff --git a/apps/desktop/src/components/pane-shell/index.ts b/apps/desktop/src/components/pane-shell/index.ts index 1874b4bf0051..d29fc10cb4d8 100644 --- a/apps/desktop/src/components/pane-shell/index.ts +++ b/apps/desktop/src/components/pane-shell/index.ts @@ -1,4 +1,7 @@ -export type { PaneShellContextValue, PaneSlot } from './context' -export { PaneShellContext } from './context' -export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell' -export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell' +/** + * Cross-surface event: toggle-reveal a collapsed pane. Dispatched by the + * keybinds (⌘B / ⌘G / titlebar toggles on narrow viewports) with the pane id + * in `detail`; the layout tree's narrow overlays (tree/renderer.tsx) listen + * and slide the pane over the grid. + */ +export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal' diff --git a/apps/desktop/src/components/pane-shell/pane-shell.test.tsx b/apps/desktop/src/components/pane-shell/pane-shell.test.tsx deleted file mode 100644 index 99f481f05401..000000000000 --- a/apps/desktop/src/components/pane-shell/pane-shell.test.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { cleanup, fireEvent, render } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { $paneStates, setPaneOpen, setPaneWidthOverride } from '@/store/panes' - -import { Pane, PaneMain, PaneShell } from './pane-shell' - -function gridContainer(rendered: ReturnType<typeof render>): HTMLElement { - const root = rendered.container.firstElementChild - - if (!(root instanceof HTMLElement)) { - throw new Error('PaneShell did not render a root element') - } - - return root -} - -function getColumnTemplate(container: HTMLElement): string[] { - return (container.style.gridTemplateColumns ?? '').split(/\s+/).filter(Boolean) -} - -function mockWidth(element: HTMLElement, width: number) { - Object.defineProperty(element, 'getBoundingClientRect', { - configurable: true, - value: () => ({ - bottom: 0, - height: 0, - left: 0, - right: width, - top: 0, - width, - x: 0, - y: 0, - toJSON: () => ({}) - }) - }) -} - -describe('PaneShell composition', () => { - beforeEach(() => { - $paneStates.set({}) - window.localStorage.clear() - }) - - afterEach(() => { - cleanup() - $paneStates.set({}) - window.localStorage.clear() - }) - - it('builds a 2-column grid for one left pane + main', () => { - const rendered = render( - <PaneShell> - <Pane id="files" side="left" width="240px"> - files - </Pane> - <PaneMain>main</PaneMain> - </PaneShell> - ) - - const tracks = getColumnTemplate(gridContainer(rendered)) - - expect(tracks).toEqual(['240px', 'minmax(0,1fr)']) - }) - - it('orders panes left-to-right by side, preserving source order within a side', () => { - const rendered = render( - <PaneShell> - <Pane id="files" side="left" width="240px"> - files - </Pane> - <Pane id="sessions" side="left" width="200px"> - sessions - </Pane> - <PaneMain>main</PaneMain> - <Pane id="preview" side="right" width="320px"> - preview - </Pane> - <Pane id="inspector" side="right" width="280px"> - inspector - </Pane> - </PaneShell> - ) - - const tracks = getColumnTemplate(gridContainer(rendered)) - - expect(tracks).toEqual(['240px', '200px', 'minmax(0,1fr)', '320px', '280px']) - }) - - it('collapses a closed pane to 0px', () => { - const rendered = render( - <PaneShell> - <Pane defaultOpen={false} id="files" side="left" width="240px"> - files - </Pane> - <PaneMain>main</PaneMain> - </PaneShell> - ) - - const tracks = getColumnTemplate(gridContainer(rendered)) - - expect(tracks).toEqual(['0px', 'minmax(0,1fr)']) - }) - - it('reads open state from the panes store', () => { - setPaneOpen('files', false) - - const rendered = render( - <PaneShell> - <Pane id="files" side="left" width="240px"> - files - </Pane> - <PaneMain>main</PaneMain> - </PaneShell> - ) - - expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)']) - }) - - it('disabled forces the track to 0px even when the store says open', () => { - setPaneOpen('files', true) - - const rendered = render( - <PaneShell> - <Pane disabled={true} id="files" side="left" width="240px"> - files - </Pane> - <PaneMain>main</PaneMain> - </PaneShell> - ) - - expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)']) - }) - - it('disabled does NOT mutate the store-persisted open state', () => { - setPaneOpen('files', true) - - render( - <PaneShell> - <Pane disabled={true} id="files" side="left" width="240px"> - files - </Pane> - <PaneMain>main</PaneMain> - </PaneShell> - ) - - expect($paneStates.get().files?.open).toBe(true) - }) - - it('uses widthOverride from the store when set', () => { - setPaneOpen('files', true) - setPaneWidthOverride('files', 320) - - const rendered = render( - <PaneShell> - <Pane id="files" side="left" width="240px"> - files - </Pane> - <PaneMain>main</PaneMain> - </PaneShell> - ) - - expect(getColumnTemplate(gridContainer(rendered))).toEqual(['320px', 'minmax(0,1fr)']) - }) - - it('preserves CSS-string widths verbatim (clamp, var, etc.)', () => { - const rendered = render( - <PaneShell> - <Pane id="inspector" side="right" width="clamp(13.5rem,21vw,20rem)"> - inspector - </Pane> - <PaneMain>main</PaneMain> - </PaneShell> - ) - - const template = gridContainer(rendered).style.gridTemplateColumns - - expect(template).toContain('clamp(13.5rem,21vw,20rem)') - }) - - it('coerces numeric widths to px', () => { - const rendered = render( - <PaneShell> - <Pane id="files" side="left" width={224}> - files - </Pane> - <PaneMain>main</PaneMain> - </PaneShell> - ) - - expect(getColumnTemplate(gridContainer(rendered))).toEqual(['224px', 'minmax(0,1fr)']) - }) - - it('emits per-pane width as a CSS variable', () => { - const rendered = render( - <PaneShell> - <Pane id="files" side="left" width="240px"> - files - </Pane> - <PaneMain>main</PaneMain> - </PaneShell> - ) - - const root = gridContainer(rendered) - - expect(root.style.getPropertyValue('--pane-files-width').trim()).toBe('240px') - }) - - it('places a Pane in the correct grid column via inline style', () => { - const rendered = render( - <PaneShell> - <Pane id="files" side="left" width="240px"> - <span data-testid="files-content">files</span> - </Pane> - <PaneMain> - <span data-testid="main-content">main</span> - </PaneMain> - <Pane id="preview" side="right" width="320px"> - <span data-testid="preview-content">preview</span> - </Pane> - </PaneShell> - ) - - const filesCell = rendered.getByTestId('files-content').parentElement! - const mainCell = rendered.getByTestId('main-content').parentElement! - const previewCell = rendered.getByTestId('preview-content').parentElement! - - expect(filesCell.style.gridColumn).toBe('1 / 2') - expect(mainCell.style.gridColumn).toBe('2 / 3') - expect(previewCell.style.gridColumn).toBe('3 / 4') - }) - - it('marks closed panes aria-hidden', () => { - const rendered = render( - <PaneShell> - <Pane defaultOpen={false} id="files" side="left" width="240px"> - <span data-testid="files-content">files</span> - </Pane> - <PaneMain>main</PaneMain> - </PaneShell> - ) - - const cell = rendered.getByTestId('files-content').parentElement! - - expect(cell.getAttribute('aria-hidden')).toBe('true') - expect(cell.getAttribute('data-pane-open')).toBe('false') - }) - - it('passes through arbitrary non-Pane children for self-placement', () => { - const rendered = render( - <PaneShell> - <Pane id="files" side="left" width="240px"> - files - </Pane> - <PaneMain>main</PaneMain> - <div data-testid="floating-overlay" style={{ position: 'absolute' }}> - overlay - </div> - </PaneShell> - ) - - expect(rendered.getByTestId('floating-overlay')).toBeDefined() - }) - - it('shows a resize handle only when resizable', () => { - const rendered = render( - <PaneShell> - <Pane id="files" side="left" width="240px"> - files - </Pane> - <Pane id="preview" resizable side="right" width="320px"> - preview - </Pane> - <PaneMain>main</PaneMain> - </PaneShell> - ) - - expect(rendered.queryByLabelText('Resize files')).toBeNull() - expect(rendered.getByLabelText('Resize preview')).toBeDefined() - }) - - it('dragging a left-pane separator stores a wider width override', () => { - const rendered = render( - <PaneShell> - <Pane id="files" maxWidth={360} minWidth={200} resizable side="left" width="240px"> - <span data-testid="files-content">files</span> - </Pane> - <PaneMain>main</PaneMain> - </PaneShell> - ) - - const paneCell = rendered.getByTestId('files-content').parentElement - - if (!(paneCell instanceof HTMLElement)) { - throw new Error('Expected pane cell element') - } - - mockWidth(paneCell, 240) - const separator = rendered.getByLabelText('Resize files') - - fireEvent.pointerDown(separator, { clientX: 240, pointerId: 1 }) - fireEvent.pointerMove(window, { clientX: 300 }) - fireEvent.pointerUp(window, { clientX: 300 }) - - expect($paneStates.get().files?.widthOverride).toBe(300) - }) - - it('dragging a right-pane separator clamps to max width', () => { - const rendered = render( - <PaneShell> - <PaneMain>main</PaneMain> - <Pane id="preview" maxWidth={340} minWidth={220} resizable side="right" width="320px"> - <span data-testid="preview-content">preview</span> - </Pane> - </PaneShell> - ) - - const paneCell = rendered.getByTestId('preview-content').parentElement - - if (!(paneCell instanceof HTMLElement)) { - throw new Error('Expected pane cell element') - } - - mockWidth(paneCell, 320) - const separator = rendered.getByLabelText('Resize preview') - - fireEvent.pointerDown(separator, { clientX: 900, pointerId: 1 }) - fireEvent.pointerMove(window, { clientX: 760 }) - fireEvent.pointerUp(window, { clientX: 760 }) - - expect($paneStates.get().preview?.widthOverride).toBe(340) - }) -}) diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx deleted file mode 100644 index 31a3f43c873d..000000000000 --- a/apps/desktop/src/components/pane-shell/pane-shell.tsx +++ /dev/null @@ -1,598 +0,0 @@ -import { useStore } from '@nanostores/react' -import { - Children, - type CSSProperties, - isValidElement, - type ReactElement, - type ReactNode, - type PointerEvent as ReactPointerEvent, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState -} from 'react' - -import { cn } from '@/lib/utils' -import { $paneStates, ensurePaneRegistered, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes' - -import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context' - -type PaneSide = 'left' | 'right' -type WidthValue = string | number - -interface PaneRoleMarker { - __paneShellRole?: 'pane' | 'main' -} - -export interface PaneProps { - children?: ReactNode - className?: string - defaultOpen?: boolean - /** Paints a persistent hairline on the resize edge (not just the hover sash) so the pane boundary is always visible. */ - divider?: boolean - /** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */ - disabled?: boolean - /** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */ - forceCollapsed?: boolean - /** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */ - hoverReveal?: boolean - /** - * Lay the pane out as a horizontal row beneath its rail (spanning every column on - * its `side`) instead of as a vertical column. The pane then resizes on the Y axis. - * Used to drop the terminal under a crowded rail rather than squeezing another column in. - */ - bottomRow?: boolean - /** Default height of a `bottomRow` pane. */ - height?: WidthValue - /** Min/max height clamps for a `bottomRow` pane's vertical resize. */ - maxHeight?: WidthValue - minHeight?: WidthValue - /** Width of the collapsed-overlay panel. Defaults to the docked width (or its resize override); set this to render a narrower overlay than the docked pane (e.g. min width on mobile). */ - overlayWidth?: WidthValue - /** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */ - onOverlayActiveChange?: (overlayActive: boolean) => void - id: string - maxWidth?: WidthValue - minWidth?: WidthValue - resizable?: boolean - side: PaneSide - width?: WidthValue -} - -export interface PaneMainProps { - children?: ReactNode - className?: string -} - -export interface PaneShellProps { - children?: ReactNode - className?: string - style?: CSSProperties -} - -interface CollectedPane { - bottomRow: boolean - defaultOpen: boolean - disabled: boolean - forceCollapsed: boolean - height: string - id: string - resizable: boolean - side: PaneSide - width: string -} - -const DEFAULT_WIDTH = '16rem' -const DEFAULT_HEIGHT = '18rem' -const DEFAULT_RESIZE_MIN_WIDTH = 160 -const DEFAULT_RESIZE_MIN_HEIGHT = 120 - -// Resize-sash geometry per axis: `x` is a vertical bar on the inner edge of a -// column; `y` is a horizontal bar on the top edge of a bottom row. -const SASH = { - x: { - orientation: 'vertical', - bar: 'bottom-0 top-0 w-1 cursor-col-resize', - line: 'inset-y-0 left-1/2 w-px -translate-x-1/2', - hover: 'inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2' - }, - y: { - orientation: 'horizontal', - bar: 'inset-x-0 top-0 h-1 -translate-y-1/2 cursor-row-resize', - line: 'inset-x-0 top-1/2 h-px -translate-y-1/2', - hover: 'inset-x-0 top-1/2 h-(--vscode-sash-hover-size,0.25rem) -translate-y-1/2' - } -} as const - -// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast -// pass-by doesn't dwell on the trigger long enough for the delay to elapse. -const HOVER_REVEAL_SLIDE_MS = 220 -const HOVER_REVEAL_ENTER_DELAY_MS = 130 -const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)' -// Offset shadow lifting the revealed panel off the content (same both sides; -// the mirror axis is offset-x, which is 0). Same color on light + dark. -const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012' -// Edge trigger strip, inset past the OS window-resize grab area AND the -// adjacent pane's scrollbar (0.5rem, .scrollbar-dt) — the strip overlays the -// neighboring scroller's edge, so any overlap makes the scrollbar reveal the -// pane on hover and swallow its clicks (#44140). -const HOVER_REVEAL_TRIGGER_WIDTH = 14 -const HOVER_REVEAL_EDGE_GUTTER = 'calc(0.5rem + 2px)' - -// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal -// from the keyboard, since its store-open toggle is a no-op while collapsed. -export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal' - -const widthToCss = (value: WidthValue | undefined, fallback: string) => - value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value - -const remPx = () => - typeof window === 'undefined' - ? 16 - : Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16 - -const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth) -const viewportHeightPx = () => (typeof window === 'undefined' ? 800 : window.innerHeight) - -// Resolves PaneProps min/max (number | "Npx" | "Nrem" | "Nvw" | "Nvh" | "N%") to -// pixels for drag clamping. vw/% resolve against window width, vh against height. -function widthToPx(value: WidthValue | undefined) { - if (typeof value === 'number') { - return Number.isFinite(value) ? value : undefined - } - - const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|vh|%)?$/) - - if (!match) { - return undefined - } - - const n = Number.parseFloat(match[1]) - - switch (match[2]) { - case 'rem': - return n * remPx() - - case 'vh': - return (n * viewportHeightPx()) / 100 - - case 'vw': - - case '%': - return (n * viewportPx()) / 100 - - default: - return n - } -} - -function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement { - return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role -} - -function collectPanes(children: ReactNode) { - const left: CollectedPane[] = [] - const right: CollectedPane[] = [] - let mainCount = 0 - - Children.forEach(children, child => { - if (isRole(child, 'main')) { - mainCount++ - - return - } - - if (!isRole(child, 'pane')) { - return - } - - const props = child.props as PaneProps - - const entry: CollectedPane = { - bottomRow: props.bottomRow ?? false, - defaultOpen: props.defaultOpen ?? true, - disabled: props.disabled ?? false, - forceCollapsed: props.forceCollapsed ?? false, - height: widthToCss(props.height, DEFAULT_HEIGHT), - id: props.id, - resizable: props.resizable ?? false, - side: props.side, - width: widthToCss(props.width, DEFAULT_WIDTH) - } - - ;(props.side === 'left' ? left : right).push(entry) - }) - - return { left, mainCount, right } -} - -type PaneStoreState = Record<string, { open: boolean; widthOverride?: number; heightOverride?: number }> - -function paneIsOpen(pane: CollectedPane, states: PaneStoreState) { - const stateOpen = states[pane.id]?.open ?? pane.defaultOpen - - return !pane.disabled && !pane.forceCollapsed && stateOpen -} - -function trackForPane(pane: CollectedPane, states: PaneStoreState) { - const open = paneIsOpen(pane, states) - - if (!open) { - return { open: false, track: '0px' } - } - - const override = pane.resizable ? states[pane.id]?.widthOverride : undefined - - return { open: true, track: override !== undefined ? `${override}px` : pane.width } -} - -function heightTrackForPane(pane: CollectedPane, states: PaneStoreState) { - const override = pane.resizable ? states[pane.id]?.heightOverride : undefined - - return override !== undefined ? `${override}px` : pane.height -} - -export function PaneShell({ children, className, style }: PaneShellProps) { - const paneStates = useStore($paneStates) - const { left, mainCount, right } = useMemo(() => collectPanes(children), [children]) - - if (import.meta.env.DEV && mainCount > 1) { - console.warn('[PaneShell] expected at most one <PaneMain>, got', mainCount) - } - - const ctxValue = useMemo(() => { - const paneById = new Map<string, PaneSlot>() - const tracks: string[] = [] - const cssVars: Record<string, string> = {} - let column = 1 - - // A bottom-row pane drops out of its rail's column flow and instead spans - // every column on its side as a new row below them. The first open one wins - // and decides which rail gets split into two rows. - const leftCols = left.filter(pane => !pane.bottomRow) - const rightCols = right.filter(pane => !pane.bottomRow) - const bottomRowPanes = [...left, ...right].filter(pane => pane.bottomRow) - const activeBottomRow = bottomRowPanes.find(pane => paneIsOpen(pane, paneStates)) ?? null - const bottomRailSide = activeBottomRow?.side ?? null - - // Open column panes on the bottom row's side shrink to the top row; everything - // else (main, the other rail, closed / hover-reveal panes) stays full height. - const addColumn = (pane: CollectedPane, paneSide: PaneSide) => { - const { open, track } = trackForPane(pane, paneStates) - tracks.push(track) - cssVars[`--pane-${pane.id}-width`] = track - const gridRow = open && paneSide === bottomRailSide ? '1 / 2' : '1 / -1' - paneById.set(pane.id, { - open, - side: paneSide, - gridColumn: `${column} / ${column + 1}`, - gridRow, - bottomRow: false - }) - column++ - } - - for (const pane of leftCols) { - addColumn(pane, 'left') - } - - tracks.push('minmax(0,1fr)') - const mainColumn = column++ - - for (const pane of rightCols) { - addColumn(pane, 'right') - } - - // Place every bottom-row pane: span its rail's columns on the second row. - for (const pane of bottomRowPanes) { - const gridColumn = pane.side === 'left' ? `1 / ${mainColumn}` : `${mainColumn + 1} / -1` - paneById.set(pane.id, { - open: pane === activeBottomRow, - side: pane.side, - gridColumn, - gridRow: '2 / 3', - bottomRow: true - }) - } - - // Always emit explicit rows so `grid-row: 1 / -1` (full-height) resolves - // against a known last line. With a bottom row active there are two tracks; - // otherwise a single 1fr track behaves exactly like the old single-row grid. - const gridTemplateRows = activeBottomRow - ? `minmax(0,1fr) ${heightTrackForPane(activeBottomRow, paneStates)}` - : 'minmax(0,1fr)' - - return { - cssVars, - gridTemplate: tracks.join(' '), - gridTemplateRows, - mainColumn, - paneById - } satisfies PaneShellContextValue & { - cssVars: Record<string, string> - gridTemplate: string - gridTemplateRows: string - } - }, [left, paneStates, right]) - - const composedStyle = useMemo<CSSProperties>( - () => ({ - ...ctxValue.cssVars, - ...style, - gridTemplateColumns: ctxValue.gridTemplate, - gridTemplateRows: ctxValue.gridTemplateRows - }), - [ctxValue.cssVars, ctxValue.gridTemplate, ctxValue.gridTemplateRows, style] - ) - - return ( - <PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}> - <div className={cn('relative grid h-full min-h-0', className)} data-pane-shell="" style={composedStyle}> - {children} - </div> - </PaneShellContext.Provider> - ) -} - -export function Pane({ - children, - className, - defaultOpen = true, - divider = false, - disabled = false, - hoverReveal = false, - maxHeight, - minHeight, - overlayWidth: overlayWidthProp, - id, - maxWidth, - minWidth, - onOverlayActiveChange, - resizable = false, - width -}: PaneProps) { - const ctx = useContext(PaneShellContext) - const paneStates = useStore($paneStates) - const registered = useRef(false) - const paneRef = useRef<HTMLDivElement | null>(null) - // Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS. - const [forced, setForced] = useState(false) - - const slot = ctx?.paneById.get(id) - const open = Boolean(slot?.open && !disabled) - const side = slot?.side ?? 'left' - // Collapsed + hoverReveal: float the pane contents over the main column on - // hover/focus instead of hiding them. Honors any persisted resize width. - const overlayActive = !open && hoverReveal && !disabled - const override = resizable ? paneStates[id]?.widthOverride : undefined - - // Overlay width: an explicit `overlayWidth` (e.g. min width on mobile) wins, - // else the persisted resize override, else the docked width. - const overlayWidth = - overlayWidthProp !== undefined - ? widthToCss(overlayWidthProp, DEFAULT_WIDTH) - : override !== undefined - ? `${override}px` - : widthToCss(width, DEFAULT_WIDTH) - - useEffect(() => { - if (registered.current) { - return - } - - registered.current = true - ensurePaneRegistered(id, { open: defaultOpen }) - }, [defaultOpen, id]) - - // Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer - // a collapsed overlay (reopened / widened). - useEffect(() => { - if (typeof window === 'undefined' || !overlayActive) { - setForced(false) - - return - } - - const onToggle = (e: Event) => { - if ((e as CustomEvent<{ id: string }>).detail?.id === id) { - setForced(v => !v) - } - } - - window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) - - return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) - }, [id, overlayActive]) - - // Keep contents mounted while collapsed so reveal is a pure CSS transform. - useEffect(() => { - onOverlayActiveChange?.(overlayActive) - }, [onOverlayActiveChange, overlayActive]) - - const isBottomRow = Boolean(slot?.bottomRow) - const axis = isBottomRow ? 'y' : 'x' - const sash = SASH[axis] - const canResize = open && resizable - const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH - const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY - const loH = widthToPx(minHeight) ?? DEFAULT_RESIZE_MIN_HEIGHT - const hiH = widthToPx(maxHeight) ?? Number.POSITIVE_INFINITY - - // One pointer-drag for both axes. Columns grow toward the main column (left - // rail → right, right rail → left); the bottom row grows up from its top edge. - const startResize = useCallback( - (event: ReactPointerEvent<HTMLDivElement>, axis: 'x' | 'y') => { - const rect = paneRef.current?.getBoundingClientRect() - const base = (axis === 'x' ? rect?.width : rect?.height) ?? 0 - - if (!canResize || base <= 0) { - return - } - - event.preventDefault() - - const handle = event.currentTarget - const { pointerId } = event - const start = axis === 'x' ? event.clientX : event.clientY - const dir = axis === 'x' ? (side === 'left' ? 1 : -1) : -1 - const [min, max] = axis === 'x' ? [lo, hi] : [loH, hiH] - const apply = axis === 'x' ? setPaneWidthOverride : setPaneHeightOverride - const restoreCursor = document.body.style.cursor - const restoreSelect = document.body.style.userSelect - - handle.setPointerCapture?.(pointerId) - document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize' - document.body.style.userSelect = 'none' - - const onMove = (e: PointerEvent) => { - const next = base + ((axis === 'x' ? e.clientX : e.clientY) - start) * dir - apply(id, Math.round(Math.min(max, Math.max(min, next)))) - } - - const cleanup = () => { - document.body.style.cursor = restoreCursor - document.body.style.userSelect = restoreSelect - handle.releasePointerCapture?.(pointerId) - window.removeEventListener('pointermove', onMove, true) - window.removeEventListener('pointerup', cleanup, true) - window.removeEventListener('pointercancel', cleanup, true) - window.removeEventListener('blur', cleanup) - } - - window.addEventListener('pointermove', onMove, true) - window.addEventListener('pointerup', cleanup, true) - window.addEventListener('pointercancel', cleanup, true) - window.addEventListener('blur', cleanup) - }, - [canResize, hi, hiH, id, lo, loH, side] - ) - - if (!ctx) { - if (import.meta.env.DEV) { - console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`) - } - - return null - } - - if (!slot) { - return null - } - - // Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a - // thin edge trigger + the floating panel (both absolute, escaping the zero - // box). group-hover (or data-forced from the keyboard) drives the slide; the - // enter-delay is the hover-intent gate. No JS pointer math. - if (overlayActive) { - const edge = side === 'left' ? 'left' : 'right' - const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]' - - return ( - <div - className={cn('group/reveal pointer-events-none relative min-w-0', className)} - data-forced={forced ? '' : undefined} - data-pane-hover-reveal={forced ? 'open' : 'closed'} - data-pane-id={id} - data-pane-open="false" - data-pane-side={side} - ref={paneRef} - style={{ gridColumn: slot.gridColumn, gridRow: slot.gridRow }} - > - <div - aria-hidden="true" - className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]" - data-pane-reveal-trigger="" - style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }} - /> - - {/* Keyed on side so flipping panes remounts off-screen on the new edge - instead of transitioning the transform across the viewport. */} - <div - className={cn( - 'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0', - offscreen, - 'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]', - 'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]' - )} - key={edge} - style={ - { - [edge]: 0, - width: overlayWidth, - '--reveal-shadow': HOVER_REVEAL_SHADOW, - transitionDuration: `${HOVER_REVEAL_SLIDE_MS}ms`, - transitionTimingFunction: HOVER_REVEAL_EASE, - '--reveal-enter-delay': `${HOVER_REVEAL_ENTER_DELAY_MS}ms` - } as CSSProperties - } - > - <div className="flex h-full w-full flex-col">{children}</div> - </div> - </div> - ) - } - - return ( - <div - aria-hidden={!open} - className={cn('relative min-h-0 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)} - data-pane-id={id} - data-pane-open={open ? 'true' : 'false'} - data-pane-side={slot.side} - ref={paneRef} - style={{ gridColumn: slot.gridColumn, gridRow: slot.gridRow }} - > - {canResize && ( - <div - aria-label={`Resize ${id}`} - aria-orientation={sash.orientation} - className={cn( - 'group absolute z-20 [-webkit-app-region:no-drag]', - sash.bar, - !isBottomRow && (slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2') - )} - onPointerDown={e => startResize(e, axis)} - role="separator" - tabIndex={0} - > - {divider && <span className={cn('absolute bg-(--ui-stroke-secondary)', sash.line)} />} - <span - className={cn( - 'absolute bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100', - sash.hover - )} - /> - </div> - )} - {children} - </div> - ) -} - -;(Pane as unknown as PaneRoleMarker).__paneShellRole = 'pane' - -export function PaneMain({ children, className }: PaneMainProps) { - const ctx = useContext(PaneShellContext) - - if (!ctx) { - if (import.meta.env.DEV) { - console.warn('[PaneMain] must be rendered inside <PaneShell>') - } - - return null - } - - return ( - <div - className={cn('flex min-h-0 min-w-0 flex-col overflow-hidden', className)} - data-pane-main="true" - style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}`, gridRow: '1 / -1' }} - > - {children} - </div> - ) -} - -;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main' diff --git a/apps/desktop/src/components/pane-shell/tree/grid-model.ts b/apps/desktop/src/components/pane-shell/tree/grid-model.ts new file mode 100644 index 000000000000..327b210d5580 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/grid-model.ts @@ -0,0 +1,621 @@ +/** + * FancyZones grid model — a faithful port of PowerToys' + * `FancyZonesEditor/Models/GridLayoutModel.cs` + `FancyZonesEditor/GridData.cs` + * (microsoft/PowerToys, MIT). Function/field names, algorithms, and invariants + * follow the C# sources so behavior matches the original editor: + * + * - A layout is rows x columns with percent tracks summing to MULTIPLIER + * (10000) and a cellChildMap assigning each cell to a zone; a zone spanning + * multiple cells appears as the same index in adjacent cells. + * - Zones are rectangles in the 0..10000 coordinate space (prefix sums). + * - Resizers are the shared edges between zones, derived from cellChildMap + * discontinuities; dragging one moves every zone touching that edge. + * - Merging computes the rectangular CLOSURE of the selection (extending it + * until no zone is partially cut) — the signature FancyZones merge feel. + */ + +// The sum of row/column percents should be equal to this number. +export const MULTIPLIER = 10000 + +/** Minimum zone extent in model units (editor ergonomics; C# uses 1). */ +export const MIN_ZONE_SIZE = 500 + +export interface GridLayout { + rows: number + columns: number + rowPercents: number[] + columnPercents: number[] + /** cellChildMap[row][col] = zone index; spans = same index in adjacent cells. */ + cellChildMap: number[][] +} + +export interface GridZone { + index: number + left: number + top: number + right: number + bottom: number +} + +export interface GridResizer { + orientation: 'horizontal' | 'vertical' + /** All zones to the left/up, in order. */ + negativeSideIndices: number[] + /** All zones to the right/down, in order. */ + positiveSideIndices: number[] +} + +// --------------------------------------------------------------------------- +// GridData helpers (verbatim) +// --------------------------------------------------------------------------- + +/** result[k] is the sum of the first k elements of the given list. */ +export function prefixSum(list: number[]): number[] { + const result: number[] = [0] + let sum = 0 + + for (const value of list) { + sum += value + result.push(sum) + } + + return result +} + +/** Opposite of prefixSum: differences of consecutive elements. */ +function adjacentDifference(list: number[]): number[] { + if (list.length <= 1) { + return [] + } + + const result: number[] = [] + + for (let i = 0; i < list.length - 1; i++) { + result.push(list[i + 1] - list[i]) + } + + return result +} + +/** Contiguous-segment unique (order-preserving), as in GridData.Unique. */ +function unique(list: number[]): number[] { + const result: number[] = [] + + if (list.length === 0) { + return result + } + + let last = list[0] + result.push(last) + + for (let i = 1; i < list.length; i++) { + if (list[i] !== last) { + last = list[i] + result.push(last) + } + } + + return result +} + +// --------------------------------------------------------------------------- +// Model -> zones / resizers (GridData.ModelToZones / ModelToResizers) +// --------------------------------------------------------------------------- + +export function modelToZones(model: GridLayout): GridZone[] | null { + const { rows, columns: cols, cellChildMap } = model + + let zoneCount = 0 + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + zoneCount = Math.max(zoneCount, cellChildMap[row][col]) + } + } + + zoneCount++ + + if (zoneCount > rows * cols) { + return null + } + + const indexCount = new Array<number>(zoneCount).fill(0) + const indexRowLow = new Array<number>(zoneCount).fill(Number.MAX_SAFE_INTEGER) + const indexRowHigh = new Array<number>(zoneCount).fill(0) + const indexColLow = new Array<number>(zoneCount).fill(Number.MAX_SAFE_INTEGER) + const indexColHigh = new Array<number>(zoneCount).fill(0) + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const index = cellChildMap[row][col] + indexCount[index]++ + indexRowLow[index] = Math.min(indexRowLow[index], row) + indexColLow[index] = Math.min(indexColLow[index], col) + indexRowHigh[index] = Math.max(indexRowHigh[index], row) + indexColHigh[index] = Math.max(indexColHigh[index], col) + } + } + + for (let index = 0; index < zoneCount; index++) { + if (indexCount[index] === 0) { + return null + } + + // Each zone must occupy a full rectangle of cells. + if (indexCount[index] !== (indexRowHigh[index] - indexRowLow[index] + 1) * (indexColHigh[index] - indexColLow[index] + 1)) { + return null + } + } + + if ( + model.rowPercents.length !== rows || + model.columnPercents.length !== cols || + model.rowPercents.some(x => x < 1) || + model.columnPercents.some(x => x < 1) + ) { + return null + } + + const rowPrefixSum = prefixSum(model.rowPercents) + const colPrefixSum = prefixSum(model.columnPercents) + + if (rowPrefixSum[rows] !== MULTIPLIER || colPrefixSum[cols] !== MULTIPLIER) { + return null + } + + const zones: GridZone[] = [] + + for (let index = 0; index < zoneCount; index++) { + zones.push({ + index, + left: colPrefixSum[indexColLow[index]], + right: colPrefixSum[indexColHigh[index] + 1], + top: rowPrefixSum[indexRowLow[index]], + bottom: rowPrefixSum[indexRowHigh[index] + 1] + }) + } + + return zones +} + +export function modelToResizers(model: GridLayout): GridResizer[] { + const grid = model.cellChildMap + const { rows, columns: cols } = model + const resizers: GridResizer[] = [] + + // Horizontal + for (let row = 1; row < rows; row++) { + for (let startCol = 0; startCol < cols; ) { + if (grid[row - 1][startCol] !== grid[row][startCol]) { + let endCol = startCol + + while (endCol + 1 < cols && grid[row - 1][endCol + 1] !== grid[row][endCol + 1]) { + endCol++ + } + + const positive: number[] = [] + const negative: number[] = [] + + for (let col = startCol; col <= endCol; col++) { + negative.push(grid[row - 1][col]) + positive.push(grid[row][col]) + } + + resizers.push({ + orientation: 'horizontal', + positiveSideIndices: unique(positive), + negativeSideIndices: unique(negative) + }) + + startCol = endCol + 1 + } else { + startCol++ + } + } + } + + // Vertical + for (let col = 1; col < cols; col++) { + for (let startRow = 0; startRow < rows; ) { + if (grid[startRow][col - 1] !== grid[startRow][col]) { + let endRow = startRow + + while (endRow + 1 < rows && grid[endRow + 1][col - 1] !== grid[endRow + 1][col]) { + endRow++ + } + + const positive: number[] = [] + const negative: number[] = [] + + for (let row = startRow; row <= endRow; row++) { + negative.push(grid[row][col - 1]) + positive.push(grid[row][col]) + } + + resizers.push({ + orientation: 'vertical', + positiveSideIndices: unique(positive), + negativeSideIndices: unique(negative) + }) + + startRow = endRow + 1 + } else { + startRow++ + } + } + } + + return resizers +} + +// --------------------------------------------------------------------------- +// Zones -> model (GridData.ZonesToModel) +// --------------------------------------------------------------------------- + +export function zonesToModel(zones: GridZone[]): GridLayout { + const xCoords = [...new Set(zones.flatMap(z => [z.left, z.right]))].sort((a, b) => a - b) + const yCoords = [...new Set(zones.flatMap(z => [z.top, z.bottom]))].sort((a, b) => a - b) + + const model: GridLayout = { + rows: yCoords.length - 1, + columns: xCoords.length - 1, + rowPercents: adjacentDifference(yCoords), + columnPercents: adjacentDifference(xCoords), + cellChildMap: Array.from({ length: yCoords.length - 1 }, () => new Array<number>(xCoords.length - 1).fill(0)) + } + + for (let index = 0; index < zones.length; index++) { + const zone = zones[index] + const startRow = yCoords.indexOf(zone.top) + const endRow = yCoords.indexOf(zone.bottom) + const startCol = xCoords.indexOf(zone.left) + const endCol = xCoords.indexOf(zone.right) + + for (let row = startRow; row < endRow; row++) { + for (let col = startCol; col < endCol; col++) { + model.cellChildMap[row][col] = index + } + } + } + + return model +} + +// --------------------------------------------------------------------------- +// Closure + merge (GridData.ComputeClosure / DoMerge) +// --------------------------------------------------------------------------- + +function computeClosure(zones: GridZone[], indices: number[]): { indices: number[]; zone: GridZone } { + let left = Number.MAX_SAFE_INTEGER + let right = Number.MIN_SAFE_INTEGER + let top = Number.MAX_SAFE_INTEGER + let bottom = Number.MIN_SAFE_INTEGER + + if (indices.length === 0) { + return { indices: [], zone: { index: -1, left, right, top, bottom } } + } + + const extend = (zone: GridZone) => { + left = Math.min(left, zone.left) + right = Math.max(right, zone.right) + top = Math.min(top, zone.top) + bottom = Math.max(bottom, zone.bottom) + } + + for (const index of indices) { + extend(zones[index]) + } + + let possiblyBroken = true + + while (possiblyBroken) { + possiblyBroken = false + + for (const zone of zones) { + const area = (zone.bottom - zone.top) * (zone.right - zone.left) + + const cutLeft = Math.max(left, zone.left) + const cutRight = Math.min(right, zone.right) + const cutTop = Math.max(top, zone.top) + const cutBottom = Math.min(bottom, zone.bottom) + + const newArea = Math.max(0, cutBottom - cutTop) * Math.max(0, cutRight - cutLeft) + + if (newArea !== 0 && newArea !== area) { + // Bad intersection found, extend. + extend(zone) + possiblyBroken = true + } + } + } + + const resultIndices = zones + .filter(zone => left <= zone.left && zone.right <= right && top <= zone.top && zone.bottom <= bottom) + .map(zone => zone.index) + + return { indices: resultIndices, zone: { index: -1, left, right, top, bottom } } +} + +export function mergeClosureIndices(model: GridLayout, indices: number[]): number[] { + const zones = modelToZones(model) + + return zones ? computeClosure(zones, indices).indices : [] +} + +export function doMerge(model: GridLayout, indices: number[]): GridLayout { + if (indices.length === 0) { + return model + } + + const zones = modelToZones(model) + + if (!zones) { + return model + } + + const lowestIndex = Math.min(...indices) + const closure = computeClosure(zones, indices) + const closureIndices = new Set(closure.indices) + + const remaining = zones.filter(zone => !closureIndices.has(zone.index)) + remaining.splice(lowestIndex, 0, closure.zone) + + return zonesToModel(remaining) +} + +// --------------------------------------------------------------------------- +// Split (GridData.CanSplit / Split) +// --------------------------------------------------------------------------- + +export function canSplit( + model: GridLayout, + zoneIndex: number, + position: number, + orientation: 'horizontal' | 'vertical' +): boolean { + const zones = modelToZones(model) + + if (!zones || !zones[zoneIndex]) { + return false + } + + const zone = zones[zoneIndex] + + if (orientation === 'horizontal') { + return zone.top + MIN_ZONE_SIZE <= position && position <= zone.bottom - MIN_ZONE_SIZE + } + + return zone.left + MIN_ZONE_SIZE <= position && position <= zone.right - MIN_ZONE_SIZE +} + +export function splitZone( + model: GridLayout, + zoneIndex: number, + position: number, + orientation: 'horizontal' | 'vertical' +): GridLayout { + if (!canSplit(model, zoneIndex, position, orientation)) { + return model + } + + const zones = modelToZones(model)! + const zone = zones[zoneIndex] + const zone1 = { ...zone } + const zone2 = { ...zone } + + zones.splice(zoneIndex, 1) + + if (orientation === 'horizontal') { + zone1.bottom = position + zone2.top = position + } else { + zone1.right = position + zone2.left = position + } + + zones.splice(zoneIndex, 0, zone1) + zones.splice(zoneIndex + 1, 0, zone2) + + return zonesToModel(zones) +} + +// --------------------------------------------------------------------------- +// Resizer drag (GridData.CanDrag / Drag) +// --------------------------------------------------------------------------- + +export function canDrag(model: GridLayout, resizerIndex: number, delta: number): boolean { + const zones = modelToZones(model) + const resizers = modelToResizers(model) + const resizer = resizers[resizerIndex] + + if (!zones || !resizer) { + return false + } + + const getSize = (zoneIndex: number) => { + const zone = zones[zoneIndex] + + return resizer.orientation === 'vertical' ? zone.right - zone.left : zone.bottom - zone.top + } + + for (const zoneIndex of resizer.positiveSideIndices) { + if (getSize(zoneIndex) - delta < MIN_ZONE_SIZE) { + return false + } + } + + for (const zoneIndex of resizer.negativeSideIndices) { + if (getSize(zoneIndex) + delta < MIN_ZONE_SIZE) { + return false + } + } + + return true +} + +export function dragResizer(model: GridLayout, resizerIndex: number, delta: number): GridLayout { + if (!canDrag(model, resizerIndex, delta)) { + return model + } + + const zones = modelToZones(model)! + const resizer = modelToResizers(model)[resizerIndex] + + for (const zoneIndex of resizer.positiveSideIndices) { + const zone = zones[zoneIndex] + + if (resizer.orientation === 'horizontal') { + zone.top += delta + } else { + zone.left += delta + } + } + + for (const zoneIndex of resizer.negativeSideIndices) { + const zone = zones[zoneIndex] + + if (resizer.orientation === 'horizontal') { + zone.bottom += delta + } else { + zone.right += delta + } + } + + return zonesToModel(zones) +} + +// --------------------------------------------------------------------------- +// Templates + validation (GridLayoutModel) +// --------------------------------------------------------------------------- + +/** Even track sizes that sum EXACTLY to MULTIPLIER (GridLayoutModel.InitRows note). */ +function evenPercents(count: number): number[] { + const out: number[] = [] + + for (let i = 0; i < count; i++) { + out.push(Math.floor((MULTIPLIER * (i + 1)) / count) - Math.floor((MULTIPLIER * i) / count)) + } + + return out +} + +export function initColumns(count: number): GridLayout { + return { + rows: 1, + columns: count, + rowPercents: [MULTIPLIER], + columnPercents: evenPercents(count), + cellChildMap: [Array.from({ length: count }, (_, i) => i)] + } +} + +export function initRows(count: number): GridLayout { + return { + rows: count, + columns: 1, + rowPercents: evenPercents(count), + columnPercents: [MULTIPLIER], + cellChildMap: Array.from({ length: count }, (_, i) => [i]) + } +} + +export function initGrid(zoneCount: number): GridLayout { + let rows = 1 + + while (Math.floor(zoneCount / rows) >= rows) { + rows++ + } + + rows-- + let cols = Math.floor(zoneCount / rows) + + if (zoneCount % rows !== 0) { + cols++ + } + + const model: GridLayout = { + rows, + columns: cols, + rowPercents: evenPercents(rows), + columnPercents: evenPercents(cols), + cellChildMap: Array.from({ length: rows }, () => new Array<number>(cols).fill(0)) + } + + let index = 0 + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + model.cellChildMap[row][col] = index++ + + if (index === zoneCount) { + index-- + } + } + } + + return model +} + +/** + * The "Priority Grid" template (GridLayoutModel._priorityData, decoded from + * its byte format: percents are `hi * 256 + lo`). Falls back to initGrid for + * counts beyond the table, as in the original. + */ +export function initPriorityGrid(zoneCount: number): GridLayout { + if (zoneCount === 2) { + return { rows: 1, columns: 2, rowPercents: [MULTIPLIER], columnPercents: [6667, 3333], cellChildMap: [[0, 1]] } + } + + if (zoneCount === 3) { + return { + rows: 1, + columns: 3, + rowPercents: [MULTIPLIER], + columnPercents: [2500, 5000, 2500], + cellChildMap: [[0, 1, 2]] + } + } + + return initGrid(zoneCount) +} + +/** GridLayoutModel.IsModelValid, extended with the rectangular-span check. */ +export function isGridValid(model: unknown): model is GridLayout { + if (!model || typeof model !== 'object') { + return false + } + + const m = model as GridLayout + + if (typeof m.rows !== 'number' || typeof m.columns !== 'number' || m.rows <= 0 || m.columns <= 0) { + return false + } + + if ( + !Array.isArray(m.rowPercents) || + !Array.isArray(m.columnPercents) || + m.rowPercents.length !== m.rows || + m.columnPercents.length !== m.columns || + m.rowPercents.some(x => typeof x !== 'number' || x < 1) || + m.columnPercents.some(x => typeof x !== 'number' || x < 1) + ) { + return false + } + + if ( + !Array.isArray(m.cellChildMap) || + m.cellChildMap.length !== m.rows || + m.cellChildMap.some(r => !Array.isArray(r) || r.length !== m.columns || r.some(c => typeof c !== 'number')) + ) { + return false + } + + const rowPrefix = prefixSum(m.rowPercents) + const colPrefix = prefixSum(m.columnPercents) + + if (rowPrefix[m.rows] !== MULTIPLIER || colPrefix[m.columns] !== MULTIPLIER) { + return false + } + + return modelToZones(m) !== null +} diff --git a/apps/desktop/src/components/pane-shell/tree/grid-to-tree.ts b/apps/desktop/src/components/pane-shell/tree/grid-to-tree.ts new file mode 100644 index 000000000000..e69318e8053a --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/grid-to-tree.ts @@ -0,0 +1,198 @@ +/** + * Grid -> tree bridge. A FancyZones grid whose zones can be produced by + * recursive guillotine cuts (every FancyZones template, and almost every + * practical layout) converts exactly to our runtime `LayoutNode` tree: + * full-length cut lines become splits with weights from the cut positions. + * Non-guillotine arrangements (interlocking pinwheels) return null and the + * editor disables Save with an explanation. + */ + +import type { GridLayout, GridZone } from './grid-model' +import { modelToZones } from './grid-model' +import { group, type LayoutNode, normalize, split } from './model' + +function cutCandidates(zones: GridZone[], axis: 'x' | 'y'): number[] { + const coords = new Set(zones.flatMap(z => (axis === 'x' ? [z.left, z.right] : [z.top, z.bottom]))) + + // Interior lines only. + return [...coords].sort((a, b) => a - b).slice(1, -1) +} + +function isValidCut(zones: GridZone[], axis: 'x' | 'y', at: number): boolean { + return zones.every(zone => (axis === 'x' ? zone.right <= at || zone.left >= at : zone.bottom <= at || zone.top >= at)) +} + +/** + * Recursively cut the zone set. Collects ALL valid cuts on the chosen axis at + * once so "three columns" becomes one flat 3-child split, not nested pairs. + */ +function cut(zones: GridZone[], assignPane: (zoneIndex: number) => string[]): LayoutNode | null { + if (zones.length === 1) { + // Zones without panes become EMPTY groups — editor-authored drop targets + // that live until the first structural op prunes them (normalize). + return group(assignPane(zones[0].index)) + } + + for (const axis of ['x', 'y'] as const) { + const cuts = cutCandidates(zones, axis).filter(at => isValidCut(zones, axis, at)) + + if (cuts.length === 0) { + continue + } + + const start = Math.min(...zones.map(z => (axis === 'x' ? z.left : z.top))) + const end = Math.max(...zones.map(z => (axis === 'x' ? z.right : z.bottom))) + const lines = [start, ...cuts, end] + const children: LayoutNode[] = [] + const weights: number[] = [] + + for (let i = 0; i < lines.length - 1; i++) { + const lo = lines[i] + const hi = lines[i + 1] + + const slice = zones.filter(zone => + axis === 'x' ? zone.left >= lo && zone.right <= hi : zone.top >= lo && zone.bottom <= hi + ) + + if (slice.length === 0) { + continue + } + + const child = cut(slice, assignPane) + + if (child) { + children.push(child) + weights.push(hi - lo) + } + } + + if (children.length === 0) { + return null + } + + if (children.length === 1) { + return children[0] + } + + return split(axis === 'x' ? 'row' : 'column', children, weights) + } + + // No full-length cut exists on either axis: non-guillotine (pinwheel). + return null +} + +export type PanePlacementHint = 'main' | 'left' | 'right' | 'top' | 'bottom' + +export interface PlacedPane { + id: string + placement?: PanePlacementHint +} + +const CENTER = 5000 // MULTIPLIER / 2 + +interface ZoneGeo { + index: number + area: number + cx: number + cy: number +} + +/** + * Semantic zone assignment: panes claim zones by ROLE, matched on geometry — + * `main` takes the largest zone, `left`/`right`/`top`/`bottom` take zones whose + * centroid actually sits on that side (with a small size tiebreak). A hinted + * pane with no acceptable zone left STACKS with its role-mates (or main) + * instead of squatting in some random cell; unhinted panes fill what remains + * biggest-first; extra zones stay empty. + */ +function assignZones(zones: GridZone[], panes: PlacedPane[]): Map<number, string[]> { + const geo: ZoneGeo[] = zones.map(z => ({ + index: z.index, + area: (z.right - z.left) * (z.bottom - z.top), + cx: (z.left + z.right) / 2, + cy: (z.top + z.bottom) / 2 + })) + + const remaining = new Map(geo.map(g => [g.index, g])) + const assignments = new Map<number, string[]>() + const zoneForRole = new Map<string, number>() + + // Score = fit for the role; accept = the zone genuinely sits on that side. + const roles: Record<PanePlacementHint, { accept: (g: ZoneGeo) => boolean; score: (g: ZoneGeo) => number }> = { + main: { accept: () => true, score: g => g.area }, + left: { accept: g => g.cx < CENTER, score: g => CENTER - g.cx + g.area / 1e8 }, + right: { accept: g => g.cx > CENTER, score: g => g.cx - CENTER + g.area / 1e8 }, + top: { accept: g => g.cy < CENTER, score: g => CENTER - g.cy + g.area / 1e8 }, + bottom: { accept: g => g.cy > CENTER, score: g => g.cy - CENTER + g.area / 1e8 } + } + + const claim = (pane: PlacedPane, role: PanePlacementHint | '_') => { + const spec = role === '_' ? roles.main : roles[role] + let best: ZoneGeo | null = null + + for (const g of remaining.values()) { + if (spec.accept(g) && (!best || spec.score(g) > spec.score(best))) { + best = g + } + } + + if (best) { + remaining.delete(best.index) + assignments.set(best.index, [pane.id]) + zoneForRole.set(role, best.index) + + if (role === 'main' || !zoneForRole.has('main')) { + zoneForRole.set('main', zoneForRole.get('main') ?? best.index) + } + + return + } + + // No acceptable zone left: stack with role-mates, else with main, else last. + const home = zoneForRole.get(role) ?? zoneForRole.get('main') ?? [...assignments.keys()].pop() + + if (home !== undefined) { + assignments.get(home)?.push(pane.id) + } + } + + // Placement priority: main anchors first, then sided panes, then the rest. + const rank = (p: PlacedPane) => (p.placement === 'main' ? 0 : p.placement ? 1 : 2) + + for (const pane of [...panes].sort((a, b) => rank(a) - rank(b))) { + claim(pane, pane.placement ?? '_') + } + + return assignments +} + +/** + * Convert a grid to a tree. Panes carry placement hints (from their + * contribution's `data.placement`) and land in geometrically fitting zones; + * zones beyond the pane count stay as EMPTY zones. + */ +export function gridToTree(gridModel: GridLayout, panes: PlacedPane[]): LayoutNode | null { + const zones = modelToZones(gridModel) + + if (!zones || zones.length === 0 || panes.length === 0) { + return null + } + + const assignments = assignZones(zones, panes) + const result = cut(zones, zoneIndex => assignments.get(zoneIndex) ?? []) + + return result ? normalize(result) : null +} + +/** True when the grid is expressible as a tree (guillotine-cuttable). */ +export function gridIsTreeExpressible(gridModel: GridLayout): boolean { + const zones = modelToZones(gridModel) + + if (!zones) { + return false + } + + const probe = cut(zones, () => ['probe']) + + return probe !== null +} diff --git a/apps/desktop/src/components/pane-shell/tree/model.ts b/apps/desktop/src/components/pane-shell/tree/model.ts new file mode 100644 index 000000000000..f1d09acdd4d3 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/model.ts @@ -0,0 +1,617 @@ +/** + * Layout tree model — the Dockview-style structure that replaces the + * rails/bands grammar. Two node kinds: + * + * - `split`: children laid out along an orientation with fractional weights. + * - `group`: a stack of panes (tabs) with one active; may be minimized to + * its header strip (DetailPane semantics). + * + * Everything the old grammar special-cased is just tree shape here: a "top row + * spanning the right rail" is a column split; "a cell inside a column" is a + * stacked group; spans fall out of tree position. All operations are pure and + * return new trees; `normalize` keeps the structure canonical (no empty + * groups, no single-child or same-orientation nested splits). + */ + +export type Orientation = 'row' | 'column' + +export interface SplitNode { + type: 'split' + id: string + orientation: Orientation + children: LayoutNode[] + /** Parallel to children; relative flex weights. */ + weights: number[] +} + +export interface GroupNode { + type: 'group' + id: string + /** Pane ids stacked in this group (rendered as tabs when > 1). */ + panes: string[] + /** The visible pane. */ + active: string + /** Collapsed to header strip (chevron restores). */ + minimized?: boolean + /** + * Header hidden entirely (double-click the header to hide, double-click the + * zone's top edge to bring it back). Minimize always shows the header — + * a minimized group IS its header. + */ + headerHidden?: boolean +} + +export type LayoutNode = SplitNode | GroupNode + +/** Where a dragged pane lands relative to a target group. */ +export type DropPosition = 'center' | 'left' | 'right' | 'top' | 'bottom' + +export type RootEdge = 'left' | 'right' | 'top' | 'bottom' + +let seq = 0 +export const nodeId = (kind: string) => `${kind}-${Date.now().toString(36)}-${(seq++).toString(36)}` + +export const group = (panes: string[], options?: Partial<Omit<GroupNode, 'type' | 'panes'>>): GroupNode => ({ + type: 'group', + id: options?.id ?? nodeId('g'), + panes, + active: options?.active ?? panes[0] ?? '', + minimized: options?.minimized, + headerHidden: options?.headerHidden +}) + +export const split = ( + orientation: Orientation, + children: LayoutNode[], + weights?: number[], + id?: string +): SplitNode => ({ + type: 'split', + id: id ?? nodeId('s'), + orientation, + children, + weights: weights ?? children.map(() => 1) +}) + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +export function findGroup(node: LayoutNode, groupId: string): GroupNode | null { + if (node.type === 'group') { + return node.id === groupId ? node : null + } + + for (const child of node.children) { + const hit = findGroup(child, groupId) + + if (hit) { + return hit + } + } + + return null +} + +export function findGroupOfPane(node: LayoutNode, paneId: string): GroupNode | null { + if (node.type === 'group') { + return node.panes.includes(paneId) ? node : null + } + + for (const child of node.children) { + const hit = findGroupOfPane(child, paneId) + + if (hit) { + return hit + } + } + + return null +} + +export function allPaneIds(node: LayoutNode): string[] { + return node.type === 'group' ? [...node.panes] : node.children.flatMap(allPaneIds) +} + +// --------------------------------------------------------------------------- +// Structural edits (pure) +// --------------------------------------------------------------------------- + +/** + * Canonical form: unwrap single-child splits, flatten same-orientation + * nesting (weights scaled into the parent's slot), and PRUNE EMPTY GROUPS — + * dragging the last pane out of a zone closes the zone and its siblings + * absorb the space (VS Code semantics). Keeping empties as "stable regions" + * (the original FancyZones rule) let invisible residue accumulate into + * corrupt-feeling structure (`row([]|[])` eating half a slot); authored + * empty zones still exist inside the zone editor's own grid model, and an + * editor-applied tree keeps them until the first structural op. + */ +export function normalize(node: LayoutNode): LayoutNode | null { + if (node.type === 'group') { + if (node.panes.length === 0) { + return null + } + + const active = node.panes.includes(node.active) ? node.active : node.panes[0] + // A zone down to one pane clears a redundant HIDDEN override (the lone-pane + // default is already headerless) but KEEPS an explicit SHOWN override — + // once a zone has ever had a tab bar, closing back to one tab leaves it + // shown (sticky bar; the off switch is "Hide tab bar"). `false` survives. + const headerHidden = node.panes.length <= 1 && node.headerHidden !== false ? undefined : node.headerHidden + + if (active === node.active && headerHidden === node.headerHidden) { + return node + } + + return { ...node, active, headerHidden } + } + + const children: LayoutNode[] = [] + const weights: number[] = [] + + node.children.forEach((child, i) => { + const kept = normalize(child) + + if (!kept) { + return + } + + if (kept.type === 'split' && kept.orientation === node.orientation) { + // Flatten: distribute this slot's weight across the flattened children + // proportionally to their internal weights. + const total = kept.weights.reduce((a, b) => a + b, 0) || 1 + kept.children.forEach((grandchild, j) => { + children.push(grandchild) + weights.push((node.weights[i] ?? 1) * ((kept.weights[j] ?? 1) / total)) + }) + + return + } + + children.push(kept) + weights.push(node.weights[i] ?? 1) + }) + + if (children.length === 0) { + return null + } + + if (children.length === 1) { + return children[0] + } + + return { ...node, children, weights } +} + +/** Remove a pane wherever it lives. Closing the ACTIVE tab activates its + * previous neighbor (the next one when it was first) — browser-tab feel, + * never a jump to the strip's start. */ +export function removePane(node: LayoutNode, paneId: string): LayoutNode | null { + const walk = (n: LayoutNode): LayoutNode => { + if (n.type === 'group') { + const at = n.panes.indexOf(paneId) + + if (at === -1) { + return n + } + + const panes = n.panes.filter(p => p !== paneId) + + return { ...n, panes, active: n.active === paneId ? panes[Math.max(0, at - 1)] : n.active } + } + + return { ...n, children: n.children.map(walk) } + } + + return normalize(walk(node)) +} + +/** + * Insert `paneId` at `target` group: `center` joins the stack (as a tab); + * an edge splits the group in that direction. If the neighboring split + * already runs in that orientation the new group is spliced in beside the + * target instead of nesting (normalize would flatten it anyway). + */ +export function insertAtGroup( + node: LayoutNode, + targetGroupId: string, + paneId: string, + pos: DropPosition, + /** Center drops only: stack BEFORE this pane id (`null`/omitted = append) — + * the tab-strip insertion divider's slot. */ + before?: null | string, + /** Front the inserted pane — TRUE for a gesture (drop/reveal), FALSE for silent + * adoption (logs stacking into the terminal zone must not steal its tab). */ + activate: boolean = true +): LayoutNode | null { + const walk = (n: LayoutNode): LayoutNode => { + if (n.type === 'group') { + if (n.id !== targetGroupId) { + return n + } + + if (pos === 'center') { + const at = before ? n.panes.indexOf(before) : -1 + const panes = at >= 0 ? [...n.panes.slice(0, at), paneId, ...n.panes.slice(at)] : [...n.panes, paneId] + + // Gaining a pane pins the header EXPLICITLY shown (not just cleared): + // a stack you can't see is a trap, and once a zone has ever stacked + // the bar STAYS when it drops back to one tab — the auto-hide flicker + // while dragging tabs around felt broken. Hiding is the user's call + // (double-click / zone menu). Active moves only on a gesture; an empty + // target has no prior tab, so the newcomer takes it regardless. + const active = activate || n.panes.length === 0 ? paneId : n.active + + return { ...n, panes, active, headerHidden: false } + } + + const orientation: Orientation = pos === 'left' || pos === 'right' ? 'row' : 'column' + const leading = pos === 'left' || pos === 'top' + const added = group([paneId]) + const children = leading ? [added, n] : [n, added] + + return split(orientation, children, [1, 1]) + } + + return { ...n, children: n.children.map(walk) } + } + + return normalize(walk(node)) +} + +/** + * The tree's VISIBLE shape: pane stacks + split orientations, with empty + * groups skipped (editor-session trees may still hold them) and single-child + * runs unwrapped. Two trees with equal signatures are indistinguishable on + * screen regardless of node ids. + */ +function shapeSignature(node: LayoutNode): string { + if (node.type === 'group') { + return node.panes.length > 0 ? `[${node.panes.join(',')}]` : '' + } + + const children = node.children.map(shapeSignature).filter(Boolean) + + if (children.length === 0) { + return '' + } + + return children.length === 1 ? children[0] : `${node.orientation}(${children.join('|')})` +} + +/** + * Move = remove + insert. If the target group vanished during removal (the + * pane was its only occupant), the move is a no-op. A move whose result + * LOOKS identical to the current layout is also a no-op — e.g. a "split + * bottom" drop onto the zone the pane already sits alone below would only + * rebuild the same arrangement under a fresh zone id. + */ +export function movePane( + root: LayoutNode, + paneId: string, + target: { groupId: string; pos: DropPosition; before?: null | string } +): LayoutNode { + const from = findGroupOfPane(root, paneId) + + // No-op guards: dropping a pane onto its own single-pane group. + if (from && from.id === target.groupId && from.panes.length === 1) { + return root + } + + const without = removePane(root, paneId) + + if (!without) { + // The pane was the only thing in the tree. + return root + } + + if (!findGroup(without, target.groupId)) { + return root + } + + const next = insertAtGroup(without, target.groupId, paneId, target.pos, target.before) ?? root + + return shapeSignature(next) === shapeSignature(root) ? root : next +} + +/** Group ids of every leaf under a node, in tree order. */ +export function groupLeafIds(node: LayoutNode): string[] { + return node.type === 'group' ? [node.id] : node.children.flatMap(groupLeafIds) +} + +function pathToGroup(node: LayoutNode, groupId: string): LayoutNode[] | null { + if (node.type === 'group') { + return node.id === groupId ? [node] : null + } + + for (const child of node.children) { + const sub = pathToGroup(child, groupId) + + if (sub) { + return [node, ...sub] + } + } + + return null +} + +const OPPOSITE_EDGE: Record<RootEdge, RootEdge> = { bottom: 'top', left: 'right', right: 'left', top: 'bottom' } + +/** The viable group touching `edge` of this subtree. Along the edge's axis + * children are scanned edge-first — a non-viable zone is display:none, so the + * next sibling IS the visual edge; across it, every child touches the edge. */ +function edgeGroup(node: LayoutNode, edge: RootEdge, viable: (g: GroupNode) => boolean): GroupNode | null { + if (node.type === 'group') { + return viable(node) ? node : null + } + + const along = (node.orientation === 'row') === (edge === 'left' || edge === 'right') + const children = along && (edge === 'right' || edge === 'bottom') ? [...node.children].reverse() : node.children + + for (const child of children) { + const hit = edgeGroup(child, edge, viable) + + if (hit) { + return hit + } + } + + return null +} + +/** + * The viable zone VISUALLY adjacent to `groupId` on `side` (the target of the + * zone menu's "Move left/right/up/down"). Walks up to the nearest ancestor + * split running along that axis with a sibling on that side, then descends to + * the sibling's closest viable leaf; subtrees whose every zone fails `viable` + * (all panes hidden) are skipped, matching their collapsed rendering. + */ +export function adjacentGroup( + root: LayoutNode, + groupId: string, + side: RootEdge, + viable: (g: GroupNode) => boolean +): GroupNode | null { + const path = pathToGroup(root, groupId) + + if (!path) { + return null + } + + const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column' + const forward = side === 'right' || side === 'bottom' + + for (let i = path.length - 2; i >= 0; i--) { + const parent = path[i] + + if (parent.type !== 'split' || parent.orientation !== orientation) { + continue + } + + const index = parent.children.indexOf(path[i + 1]) + + const siblings = forward ? parent.children.slice(index + 1) : parent.children.slice(0, index).reverse() + + for (const sibling of siblings) { + const hit = edgeGroup(sibling, OPPOSITE_EDGE[side], viable) + + if (hit) { + return hit + } + } + } + + return null +} + +function sameSet(ids: string[], set: Set<string>): boolean { + return ids.length === set.size && ids.every(id => set.has(id)) +} + +/** The node whose complete leaf set equals `set` (a rectangular region in a + * guillotine tree is always exactly one subtree), or null. */ +function findCover(node: LayoutNode, set: Set<string>): LayoutNode | null { + if (sameSet(groupLeafIds(node), set)) { + return node + } + + if (node.type === 'split') { + for (const child of node.children) { + const hit = findCover(child, set) + + if (hit) { + return hit + } + } + } + + return null +} + +/** + * FancyZones span: merge the highlighted zones into ONE group holding + * `paneId`, absorbing any panes that lived in those zones as tabs. Only works + * when the highlighted set forms a rectangular subtree (it always does for a + * combined zone range on a guillotine tree); returns null otherwise so the + * caller can fall back to a single-zone drop. + */ +export function mergeZonesWithPane(root: LayoutNode, groupIds: string[], paneId: string): LayoutNode | null { + const set = new Set(groupIds) + + if (set.size <= 1 || !findCover(root, set)) { + return null + } + + // Panes from the merged zones (tree order), minus the dragged one. + const panesInSet: string[] = [] + + const collect = (n: LayoutNode) => { + if (n.type === 'group') { + if (set.has(n.id)) { + panesInSet.push(...n.panes.filter(p => p !== paneId)) + } + } else { + n.children.forEach(collect) + } + } + + collect(root) + + // If the dragged pane lives OUTSIDE the merged set, pull it from its origin + // first (leaving that origin an empty zone). Inside the set it's absorbed. + const origin = findGroupOfPane(root, paneId) + let working = root + + if (origin && !set.has(origin.id)) { + working = removePane(root, paneId) ?? root + } + + const merged = group([paneId, ...panesInSet]) + + const replace = (n: LayoutNode): LayoutNode => { + if (sameSet(groupLeafIds(n), set)) { + return merged + } + + return n.type === 'split' ? { ...n, children: n.children.map(replace) } : n + } + + return normalize(replace(working)) +} + +// --------------------------------------------------------------------------- +// Attribute edits +// --------------------------------------------------------------------------- + +function mapGroups(node: LayoutNode, fn: (g: GroupNode) => GroupNode): LayoutNode { + return node.type === 'group' ? fn(node) : { ...node, children: node.children.map(c => mapGroups(c, fn)) } +} + +export function setActivePane(root: LayoutNode, groupId: string, paneId: string): LayoutNode { + return mapGroups(root, g => (g.id === groupId && g.panes.includes(paneId) ? { ...g, active: paneId } : g)) +} + +/** Reorder a pane within its group's tab stack (browser-tab drag semantics). */ +export function reorderPaneInGroup(root: LayoutNode, groupId: string, paneId: string, toIndex: number): LayoutNode { + return mapGroups(root, g => { + if (g.id !== groupId || !g.panes.includes(paneId)) { + return g + } + + const without = g.panes.filter(p => p !== paneId) + const index = Math.max(0, Math.min(without.length, toIndex)) + const panes = [...without.slice(0, index), paneId, ...without.slice(index)] + + return { ...g, panes } + }) +} + +export function setGroupMinimized(root: LayoutNode, groupId: string, minimized: boolean): LayoutNode { + return mapGroups(root, g => (g.id === groupId ? { ...g, minimized } : g)) +} + +export function setGroupHeaderHidden(root: LayoutNode, groupId: string, headerHidden: boolean): LayoutNode { + return mapGroups(root, g => (g.id === groupId ? { ...g, headerHidden } : g)) +} + +function replaceNode(node: LayoutNode, id: string, make: (g: GroupNode) => LayoutNode): LayoutNode { + if (node.type === 'group') { + return node.id === id ? make(node) : node + } + + return { ...node, children: node.children.map(c => replaceNode(c, id, make)) } +} + +/** + * Split a zone: `movePaneId` (one of SEVERAL panes in the group) moves into + * the new zone on `side` — VS Code "split right", split and move in one + * gesture. A lone pane can't split away from itself: no-op (normalize prunes + * the empty zone the split would have minted). + */ +export function splitGroupZone(root: LayoutNode, groupId: string, side: RootEdge, movePaneId: string): LayoutNode { + const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column' + const before = side === 'left' || side === 'top' + + return ( + normalize( + replaceNode(root, groupId, g => { + if (g.panes.length < 2 || !g.panes.includes(movePaneId)) { + return g + } + + const added = group([movePaneId]) + const remaining = { ...g, panes: g.panes.filter(p => p !== movePaneId) } + + return split(orientation, before ? [added, remaining] : [remaining, added], [1, 1]) + }) + ) ?? root + ) +} + +/** Mirror the layout HORIZONTALLY (the titlebar flip toggle / ⌘\): reverse + * every ROW split's child order at EVERY depth, so left↔right flips + * everywhere. A right rail lands on the left with its OWN internal order + * mirrored too — so preview stays directly beside the file tree instead of + * jumping to the far edge (a shallow root-only reverse left nested rails in + * place). COLUMN splits keep their top↔bottom order (the terminal stays at + * the bottom). Its own involution: flipping twice is the identity. */ +export function mirrorTreeHorizontal(root: LayoutNode): LayoutNode { + if (root.type === 'group') { + return root + } + + const children = root.children.map(mirrorTreeHorizontal) + + return root.orientation === 'row' + ? { ...root, children: children.reverse(), weights: [...root.weights].reverse() } + : { ...root, children } +} + +export function setSplitWeights(root: LayoutNode, splitId: string, weights: number[]): LayoutNode { + if (root.type === 'split') { + if (root.id === splitId) { + return { ...root, weights } + } + + return { ...root, children: root.children.map(c => setSplitWeights(c, splitId, weights)) } + } + + return root +} + +// --------------------------------------------------------------------------- +// Validation (persisted trees are untrusted) +// --------------------------------------------------------------------------- + +export function isLayoutNode(value: unknown): value is LayoutNode { + if (!value || typeof value !== 'object') { + return false + } + + const n = value as Record<string, unknown> + + if (n.type === 'group') { + return ( + typeof n.id === 'string' && + Array.isArray(n.panes) && + n.panes.every(p => typeof p === 'string') && + typeof n.active === 'string' + ) + } + + if (n.type === 'split') { + return ( + typeof n.id === 'string' && + (n.orientation === 'row' || n.orientation === 'column') && + Array.isArray(n.children) && + n.children.length > 0 && + n.children.every(isLayoutNode) && + Array.isArray(n.weights) && + n.weights.length === n.children.length && + n.weights.every(w => typeof w === 'number' && Number.isFinite(w) && w > 0) + ) + } + + return false +} diff --git a/apps/desktop/src/components/pane-shell/tree/presets.ts b/apps/desktop/src/components/pane-shell/tree/presets.ts new file mode 100644 index 000000000000..c0eb27f874d4 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/presets.ts @@ -0,0 +1,111 @@ +/** + * Layout presets — the FancyZones treatment. + * + * A preset is a CONTRIBUTION (`area: 'layouts'`, `data: LayoutNode`): the app + * registers its bundled presets as `source: 'core'`, plugins register theirs + * exactly the same way, and user-saved presets round-trip through localStorage + * and re-register as `source: 'user'`. The picker (renderer.tsx) reads one + * uniform list via `useContributions('layouts')`. + */ + +import { registry } from '@/contrib/registry' +import { readJson, writeJson, writeKey } from '@/lib/storage' + +import { isLayoutNode, type LayoutNode } from './model' +import { $layoutTree, applyTree, markActivePreset } from './store' + +export const LAYOUTS_AREA = 'layouts' + +// v2: v1 presets predate semantic placement (see store.ts) — retire them. +const USER_KEY = 'hermes.desktop.layoutPresets.v2' + +writeKey('hermes.desktop.layoutPresets.v1', null) + +interface StoredPreset { + name: string + tree: LayoutNode +} + +const userDisposers = new Map<string, () => void>() + +function loadUserPresets(): Record<string, StoredPreset> { + const parsed = readJson<Record<string, StoredPreset>>(USER_KEY) ?? {} + const out: Record<string, StoredPreset> = {} + + for (const [id, preset] of Object.entries(parsed)) { + if (preset && typeof preset.name === 'string' && isLayoutNode(preset.tree)) { + out[id] = preset + } + } + + return out +} + +function persistUserPresets(presets: Record<string, StoredPreset>) { + writeJson(USER_KEY, presets) +} + +function registerUserPreset(id: string, preset: StoredPreset) { + userDisposers.get(id)?.() + userDisposers.set( + id, + registry.register({ id, area: LAYOUTS_AREA, source: 'user', title: preset.name, data: preset.tree }) + ) +} + +// Register persisted user presets at module load. +const userPresets = loadUserPresets() + +for (const [id, preset] of Object.entries(userPresets)) { + registerUserPreset(id, preset) +} + +/** Save any tree as a named user preset (and make it active). */ +export function saveLayoutPresetTree(name: string, tree: LayoutNode): string | null { + const trimmed = name.trim() + + if (!tree || !trimmed) { + return null + } + + const id = `user-${ + trimmed + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || Date.now().toString(36) + }` + + userPresets[id] = { name: trimmed, tree } + persistUserPresets(userPresets) + registerUserPreset(id, userPresets[id]) + markActivePreset(id) + + return id +} + +/** Save the CURRENT tree as a named user preset (and make it active). */ +export function saveCurrentLayoutAs(name: string) { + const tree = $layoutTree.get() + + if (tree) { + saveLayoutPresetTree(name, tree) + } +} + +export function deleteUserPreset(id: string) { + if (!(id in userPresets)) { + return + } + + delete userPresets[id] + persistUserPresets(userPresets) + userDisposers.get(id)?.() + userDisposers.delete(id) +} + +export const isUserPreset = (id: string) => id in userPresets + +/** Apply a preset's tree (deep-cloned so live edits never mutate the preset). */ +export function applyLayoutPreset(id: string, tree: LayoutNode) { + applyTree(structuredClone(tree), id) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts new file mode 100644 index 000000000000..d37853f8e169 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts @@ -0,0 +1,577 @@ +/** + * THE in-app drag primitive. One pointer-capture session (`startDragSession`) + * owns the machinery every in-app drag shares — threshold, rAF-coalesced + * moves, cursor/user-select chrome, ghost chip, Esc-as-top-escape-layer, + * hint publishing, teardown — and a per-kind RESOLVER supplies the semantics: + * what the pointer is over (`resolveMove` → DropHint) and what a release + * does (`onCommit`). Pane/tab drags (below) are the first resolver; the + * sidebar session drag (app/chat/session-drag.ts) is the second. Native + * HTML5 DnD is reserved for true OS boundaries (Finder file drops) — in-app + * drags never ride it, so no snap-back animation, no hostile-library + * armor, and Esc aborts synchronously. + * + * Pane drags use the FancyZones engine (zones-engine.ts, ported verbatim): + * sensitivity-radius hit testing, HighlightedZones state machine, Shift = + * select-many (combined zone range), ClosestCenter primary on drop. The + * LAYOUT STAYS FIXED and every zone lights up as a whole-region drop target; + * NOTHING moves until release (tab reorder included — the strip shows an + * insertion divider, not a live shuffle). Over a zone's TAB STRIP the drop + * stacks at the divider's slot; elsewhere the radial position picks + * center/edge. + * + * PERFORMANCE CONTRACT: the layout never restructures mid-drag, so every + * rect a resolver needs is snapshotted once at drag start (zones AND tab + * strips) and each pointermove is pure math against those caches — no + * elementsFromPoint, no getBoundingClientRect, no style writes unless a + * value actually changed. Moves are coalesced to one hit-test per animation + * frame, with the pending move flushed synchronously on release so the drop + * commits at the exact final position. + */ + +import type { PointerEvent as ReactPointerEvent } from 'react' + +import { createDragGhost, type DragGhost } from '@/lib/drag-ghost' +import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers' +import { reorderCommitHaptic, reorderStepHaptic } from '@/lib/reorder' + +import type { DropPosition } from '../model' +import { $dropHint, $treeDragging, type DropHint, mergeTreeZones, moveTreePane, reorderTreePane } from '../store' +import { type EngineZone, HighlightedZones, primaryZone, type ZoneRect } from '../zones-engine' + +const DRAG_THRESHOLD_PX = 4 + +/** Normalized radius of the elliptical CENTER region (stack/link). Outside it + * the drop targets the dominant-axis edge — the boundary curves with the + * zone's aspect ratio instead of snapping at a rigid pixel band, and corners + * ease into their nearest edge along the quadrant diagonals. */ +const CENTER_RADIUS = 0.62 + +export function snapshotZones(): EngineZone[] { + return [...document.querySelectorAll<HTMLElement>('[data-tree-group]')].map(el => { + const r = el.getBoundingClientRect() + + return { id: el.dataset.treeGroup!, rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom } } + }) +} + +/** Radial drop position within `rect`: inside the center ellipse = the center + * action (stack/link); outside, the dominant axis picks the edge (VS Code + * dock-preview geometry). `centerRadius` sizes the ellipse — larger = more + * center, slimmer curved edge bands. */ +export function radialPosition( + rect: { left: number; top: number; right: number; bottom: number }, + x: number, + y: number, + centerRadius = CENTER_RADIUS +): DropPosition { + // Zone-centered coordinates, ±1 at the edge midpoints. + const dx = ((x - rect.left) / Math.max(1, rect.right - rect.left)) * 2 - 1 + const dy = ((y - rect.top) / Math.max(1, rect.bottom - rect.top)) * 2 - 1 + + if (Math.hypot(dx, dy) < centerRadius) { + return 'center' + } + + return Math.abs(dx) >= Math.abs(dy) ? (dx < 0 ? 'left' : 'right') : dy < 0 ? 'top' : 'bottom' +} + +/** Sub-zone drop position within the zone `groupId` (radial hit-testing). */ +export function subZonePosition(zones: EngineZone[], groupId: string, x: number, y: number): DropPosition { + const rect = zones.find(zone => zone.id === groupId)?.rect + + return rect ? radialPosition(rect, x, y) : 'center' +} + +/** One tab's insertion geometry: its pane id + horizontal midpoint. */ +interface StripSlot { + id: string + mid: number +} + +const stripSlots = (strip: HTMLElement): StripSlot[] => + [...strip.querySelectorAll<HTMLElement>('[data-tree-tab]')].map(tab => { + const r = tab.getBoundingClientRect() + + return { id: tab.dataset.treeTab ?? '', mid: r.left + r.width / 2 } + }) + +/** Insertion slot from the pointer x against the OTHER tabs' midpoints: + * stack BEFORE the returned pane id (`null` = append). */ +export function slotBefore(slots: StripSlot[], x: number, excludePaneId = ''): { before: null | string } { + for (const slot of slots) { + if (slot.id === excludePaneId) { + continue + } + + if (x < slot.mid) { + return { before: slot.id } + } + } + + return { before: null } +} + +/** Drag-start snapshot of one zone's tab strip. Strips never overlap and the + * layout never restructures mid-drag, so rect containment replaces a + * per-move elementsFromPoint hit test. A drop on a strip STACKS at the + * divider's slot — the strip is where tabs live, so it wins over the radial + * top-edge band that would otherwise read as "split top". */ +export interface StripSnapshot { + groupId: string + rect: ZoneRect + slots: StripSlot[] +} + +export function snapshotStrips(): StripSnapshot[] { + return [...document.querySelectorAll<HTMLElement>('[data-zone-tabstrip]')].map(el => { + const r = el.getBoundingClientRect() + + return { + groupId: el.dataset.zoneTabstrip!, + rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom }, + slots: stripSlots(el) + } + }) +} + +export const rectContains = (rect: ZoneRect, x: number, y: number, pad = 0) => + x >= rect.left - pad && x <= rect.right + pad && y >= rect.top - pad && y <= rect.bottom + pad + +const sameHint = (a: DropHint | null, b: DropHint | null) => + a?.groupId === b?.groupId && + a?.pos === b?.pos && + a?.stack?.before === b?.stack?.before && + (a?.stack === undefined) === (b?.stack === undefined) && + (a?.groupIds?.length ?? 0) === (b?.groupIds?.length ?? 0) && + (a?.groupIds ?? []).every((id, i) => b?.groupIds?.[i] === id) + +/** Double-tap detection for drag handles. Pane handles preventDefault + * pointerdown, which suppresses native `dblclick` — so rapid same-handle + * taps are detected here instead. */ +const DOUBLE_TAP_MS = 400 +let lastTap: { key: string; time: number } | null = null + +export interface DoubleTapContext { + /** Two sub-threshold releases with the same key within DOUBLE_TAP_MS. */ + key: string + onDoubleTap: () => void +} + +// --------------------------------------------------------------------------- +// The generic drag session (machinery) — resolvers plug in below / elsewhere. +// --------------------------------------------------------------------------- + +export interface DragSessionSpec { + /** Movement crossed the drag threshold: snapshot geometry, set the drag + * store(s), dim/mark the source. Runs once. */ + onEngage(x: number, y: number): void + /** Per-frame target resolution — pure math against drag-start snapshots. + * Returns the hint to publish; `null` = deny area (no-drop cursor, a + * release commits nothing). */ + resolveMove(x: number, y: number, shift: boolean): DropHint | null + /** Release over the final published hint (already flushed to the exact + * release position). Only called for engaged drags. */ + onCommit(hint: DropHint | null): void + /** Teardown for both commit and abort — undo whatever onEngage marked. */ + onEnd?(): void + /** Sub-threshold release = a click on the handle. */ + onTap?(): void + double?: DoubleTapContext + /** Floating chip following the pointer — for drags whose source doesn't + * stay visibly "held" (a sidebar row, unlike a dimmed tab). See + * `@/lib/drag-ghost`. */ + ghost?: { label: string } +} + +/** After an ENGAGED drag, the release still synthesizes a `click` on the + * capture element — swallow exactly that one so a drag can never double as + * an activation (row resume, tab close). Committed drags see the click in + * the same task burst as pointerup; an Esc abort's click arrives with the + * eventual release, so the trap disarms right after it. */ +function suppressDragClick(committed: boolean) { + const swallow = (ev: MouseEvent) => { + ev.preventDefault() + ev.stopPropagation() + } + + window.addEventListener('click', swallow, { capture: true, once: true }) + + const disarm = () => window.setTimeout(() => window.removeEventListener('click', swallow, true), 0) + + if (committed) { + disarm() + } else { + window.addEventListener('pointerup', disarm, { capture: true, once: true }) + window.addEventListener('pointercancel', disarm, { capture: true, once: true }) + } +} + +/** + * Begin a drag session from a handle's pointerdown. A sub-threshold release + * is a click (`onTap` / `double.onDoubleTap`); past the threshold the spec's + * resolver owns targeting and the machinery owns everything else. Esc aborts + * instantly: the session registers as the TOP escape layer, tears down + * synchronously, and nothing commits. + */ +export function startDragSession(e: ReactPointerEvent<HTMLElement>, spec: DragSessionSpec) { + if (e.button !== 0) { + return + } + + const handle = e.currentTarget + const { pointerId } = e + const sx = e.clientX + const sy = e.clientY + const restoreCursor = document.body.style.cursor + const restoreSelect = document.body.style.userSelect + let engaged = false + let releaseEscapeLayer: (() => void) | null = null + let ghost: DragGhost | null = null + let cursor: string | null = null + // rAF-coalesced move processing: the raw handler only records the latest + // point; all hit testing happens at most once per frame. + let pending: { x: number; y: number; shift: boolean } | null = null + let raf = 0 + + // Cursor writes are per-frame; only touch the style when the value changes. + const setCursor = (value: string) => { + if (cursor !== value) { + cursor = value + document.body.style.cursor = value + } + } + + const publishHint = (next: DropHint | null) => { + if (!sameHint($dropHint.get(), next)) { + if (next?.stack !== undefined && $dropHint.get()?.stack?.before !== next.stack.before) { + reorderStepHaptic() + } + + $dropHint.set(next) + } + } + + const engage = (x: number, y: number) => { + engaged = true + + // Capture only once ENGAGED: pre-threshold pointer events must stay + // untouched so a plain click on the handle (and its children — a row + // body's own onClick) keeps working. Window-level listeners track the + // gesture either way. + try { + handle.setPointerCapture?.(pointerId) + } catch { + // Synthetic events (automation) have no active pointer. + } + + setCursor('grabbing') + document.body.style.userSelect = 'none' + // While dragging, Esc belongs to the drag ALONE — lower layers (edit + // mode, overlays) must not also fire on the same press. + releaseEscapeLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag) + + if (spec.ghost) { + ghost = createDragGhost(spec.ghost.label) + } + + spec.onEngage(x, y) + } + + const processMove = (x: number, y: number, shift: boolean) => { + if (!engaged) { + if (Math.hypot(x - sx, y - sy) < DRAG_THRESHOLD_PX) { + return + } + + engage(x, y) + } + + ghost?.moveTo(x, y) + + const hint = spec.resolveMove(x, y, shift) + + // Over a deny area (no target — titlebar / statusbar / gutters / + // off-window) the release cancels; the cursor says so up front. + setCursor(hint ? 'grabbing' : 'no-drop') + publishHint(hint) + } + + const flushMove = () => { + raf = 0 + + if (pending) { + const { shift, x, y } = pending + pending = null + processMove(x, y, shift) + } + } + + const onMove = (ev: PointerEvent) => { + pending = { shift: ev.shiftKey, x: ev.clientX, y: ev.clientY } + raf ||= requestAnimationFrame(flushMove) + } + + const finish = (commit: boolean) => { + if (raf) { + cancelAnimationFrame(raf) + raf = 0 + } + + // The drop must land at the FINAL pointer position, not the last painted + // frame's — flush the pending move before reading the hint. An abort + // (Esc / pointercancel) skips it: everything is discarded anyway. + if (commit) { + flushMove() + } + + document.body.style.cursor = restoreCursor + document.body.style.userSelect = restoreSelect + ghost?.destroy() + ghost = null + releaseEscapeLayer?.() + releaseEscapeLayer = null + + try { + handle.releasePointerCapture?.(pointerId) + } catch { + // Mirror of the capture guard. + } + + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + window.removeEventListener('pointercancel', onCancel, true) + window.removeEventListener('keydown', onKey, true) + + if (engaged) { + suppressDragClick(commit) + + if (commit) { + spec.onCommit($dropHint.get()) + } + } else if (commit) { + const now = Date.now() + + if (spec.double && lastTap?.key === spec.double.key && now - lastTap.time < DOUBLE_TAP_MS) { + lastTap = null + spec.double.onDoubleTap() + } else { + lastTap = spec.double ? { key: spec.double.key, time: now } : null + spec.onTap?.() + } + } + + spec.onEnd?.() + $dropHint.set(null) + $treeDragging.set(null) + } + + const onUp = () => finish(true) + const onCancel = () => finish(false) + + // Esc aborts the drag — the target selection vanishes and nothing moves, + // the universal "never mind" for an in-flight drag. Capture-phase + stop so + // it doesn't also close a pane/overlay behind the drag (the escape layer + // covers contract-following handlers; the stop covers the rest). + const onKey = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') { + ev.preventDefault() + ev.stopPropagation() + finish(false) + } + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + window.addEventListener('pointercancel', onCancel, true) + window.addEventListener('keydown', onKey, true) +} + +// --------------------------------------------------------------------------- +// Pane drag — the tree's resolver over the generic session. +// --------------------------------------------------------------------------- + +interface ReorderContext { + groupId: string + /** The tab-strip element; tabs carry `data-tree-tab={paneId}`. */ + strip: HTMLElement +} + +/** How far (px) the pointer may stray from the strip before a tab drag stops + * being a reorder and becomes a zone move (browser-tab tear-off feel). */ +const TEAR_OFF_SLACK_PX = 18 + +/** + * Begin a pane drag from any handle. A sub-threshold release is a click + * (`onTap`, used to activate tabs; rapid repeat fires `double.onDoubleTap` + * instead). With a `reorder` context (tab drags), movement inside the strip + * targets an insertion slot — the strip renders a divider at it, NOTHING + * moves until release (placement-on-release, like every other drop); tearing + * away from the strip converts the drag into a zone move. Zone mode: zones + * light up, the target's tab strip stacks at its divider slot, Shift extends + * the highlight range, release drops into the ClosestCenter primary zone. + * Esc aborts either mode. + * + * `ghostLabel` opts into the pointer-following chip (`@/lib/drag-ghost`) — the + * same "what am I holding" affordance sessions use. The in-strip dim only + * marks a tab; a header/edit-body drag or a torn-off tab has no held source + * near the pointer, so the chip carries the pane title along with it. + */ +export function startPaneDrag( + paneId: string, + e: ReactPointerEvent<HTMLElement>, + onTap?: () => void, + reorder?: ReorderContext, + double?: DoubleTapContext, + ghostLabel?: string +) { + if (e.button !== 0) { + return + } + + e.preventDefault() + e.stopPropagation() + + const highlighted = new HighlightedZones() + let zones: EngineZone[] = [] + let strips: StripSnapshot[] = [] + let mode: 'reorder' | 'zone' | null = null + let dimmed: HTMLElement | null = null + + const markSource = () => { + // The dragged tab dims for the drag's life — the divider says where it + // GOES, the dim says what MOVES. No live shuffle (placement-on-release). + dimmed ??= reorder?.strip.querySelector<HTMLElement>(`[data-tree-tab="${CSS.escape(paneId)}"]`) ?? null + dimmed?.style.setProperty('opacity', '0.45') + } + + const enterZoneMode = () => { + mode = 'zone' + // The layout never restructures mid-drag, so zone/strip rects are stable. + zones = snapshotZones() + strips = snapshotStrips() + $treeDragging.set(paneId) + markSource() + } + + // The reorder strip's geometry, snapshotted on first use (same fixed-layout + // guarantee as the zone snapshots). + let reorderSnap: { rect: ZoneRect; slots: StripSlot[] } | null = null + + const reorderStrip = () => { + if (!reorderSnap) { + const r = reorder!.strip.getBoundingClientRect() + reorderSnap = { + rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom }, + slots: stripSlots(reorder!.strip) + } + } + + return reorderSnap + } + + const withinStrip = (x: number, y: number) => + Boolean(reorder) && rectContains(reorderStrip().rect, x, y, TEAR_OFF_SLACK_PX) + + startDragSession(e, { + double, + ghost: ghostLabel ? { label: ghostLabel } : undefined, + onTap, + + onEngage(x, y) { + if (reorder && withinStrip(x, y)) { + mode = 'reorder' + markSource() + } else { + enterZoneMode() + } + }, + + resolveMove(x, y, shift) { + if (mode === 'reorder') { + if (withinStrip(x, y)) { + return { + kind: 'group', + groupId: reorder!.groupId, + groupIds: [reorder!.groupId], + pos: 'center', + stack: slotBefore(reorderStrip().slots, x, paneId) + } + } + + // Tear-off: the tab leaves the strip and becomes a zone move. + enterZoneMode() + } + + // The hint updates on highlight-set changes AND on sub-zone position + // changes (center/edge regions within the same primary zone). + const point = { x, y } + highlighted.update(zones, point, shift) + let groupIds = [...highlighted.zones()] + + // Spanning multiple zones is EXPLICIT (Shift). Without it, the seam- + // proximity capture (sensitivity radius grabs both neighbors near a + // shared edge) collapses to the primary zone — otherwise a drop near a + // seam silently merges zones the user never asked to merge. + if (!shift && groupIds.length > 1) { + const collapsed = primaryZone(zones, groupIds, point) + groupIds = collapsed ? [collapsed] : [] + } + + const groupId = groupIds.length > 0 ? (primaryZone(zones, groupIds, point) ?? undefined) : undefined + + // Over the target's TAB STRIP the drop stacks at the divider's slot; + // sub-positions only make sense for a single-zone drop (a Shift-span + // always merges, pos ignored). + const strip = + groupIds.length === 1 && groupId ? strips.find(s => s.groupId === groupId && rectContains(s.rect, x, y)) : null + + const stack = strip ? slotBefore(strip.slots, x, paneId) : undefined + + const pos: DropPosition = stack + ? 'center' + : groupIds.length === 1 && groupId + ? subZonePosition(zones, groupId, x, y) + : 'center' + + return groupIds.length > 0 ? { kind: 'group', groupId, groupIds, pos, stack } : null + }, + + onCommit(hint) { + if (mode === 'reorder' && reorder && hint?.stack !== undefined) { + // Slot -> index among the OTHER tabs (reorderPaneInGroup inserts there). + const others = [...reorder.strip.querySelectorAll<HTMLElement>('[data-tree-tab]')] + .map(el => el.dataset.treeTab) + .filter((id): id is string => Boolean(id) && id !== paneId) + + const toIndex = hint.stack.before ? others.indexOf(hint.stack.before) : others.length + + if (toIndex >= 0) { + reorderTreePane(reorder.groupId, paneId, toIndex) + reorderCommitHaptic() + } + } + + if (mode === 'zone') { + // Drop what the hint SHOWS — the overlay and the commit share one + // truth (the raw highlight set can hold both seam neighbors; the hint + // already collapsed that to the primary unless Shift made the span + // explicit). + const targets = hint?.groupIds ?? [] + + if (targets.length > 1) { + // Shift-span: merge the highlighted zones, dropping the pane across them. + mergeTreeZones([...targets], paneId, hint?.groupId ?? null) + } else if (hint?.groupId) { + // strip = stack at the divider slot; center = join the stack; + // an edge = split the zone and land there. + moveTreePane(paneId, { groupId: hint.groupId, pos: hint.pos ?? 'center', before: hint.stack?.before }) + } + } + }, + + onEnd() { + dimmed?.style.removeProperty('opacity') + highlighted.reset() + } + }) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx new file mode 100644 index 000000000000..56e5c66e22e8 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx @@ -0,0 +1,113 @@ +/** + * Edit-mode palette — the floating, draggable "Layouts" card shown while + * layout edit mode is on. It hosts the layout picker and the reset/done + * actions; its header doubles as the drag handle. Position survives edit-mode + * toggles within a session. + */ + +import { useStore } from '@nanostores/react' +import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { useI18n } from '@/i18n' +import { formatCombo } from '@/lib/keybinds/combo' +import { $bindings, bindingsFor } from '@/store/keybinds' + +import { $layoutEditMode } from '../../edit-mode' +import { resetLayoutTree } from '../store' + +import { LayoutPicker } from './layout-picker' + +// Palette position survives edit-mode toggles within a session; null = centered. +let lastPalettePos: { x: number; y: number } | null = null + +export function TreeEditBar() { + const { t } = useI18n() + const editMode = useStore($layoutEditMode) + const bindings = useStore($bindings) + const [pos, setPos] = useState(lastPalettePos) + const cardRef = useRef<HTMLDivElement>(null) + // The toggle is a `keybinds` contribution (`layout.editMode`) — show the + // user's LIVE binding, not a hardcoded chord. + const toggleCombo = bindingsFor('layout.editMode', bindings)[0] + + const startMove = useCallback((e: ReactPointerEvent<HTMLElement>) => { + if (e.button !== 0) { + return + } + + const card = cardRef.current + const parent = card?.parentElement + + if (!card || !parent) { + return + } + + e.preventDefault() + + const cardRect = card.getBoundingClientRect() + const dx = e.clientX - cardRect.x + const dy = e.clientY - cardRect.y + + const onMove = (ev: PointerEvent) => { + const parentRect = parent.getBoundingClientRect() + + const next = { + x: Math.max(4, Math.min(parentRect.width - cardRect.width - 4, ev.clientX - parentRect.x - dx)), + y: Math.max(4, Math.min(parentRect.height - 40, ev.clientY - parentRect.y - dy)) + } + + lastPalettePos = next + setPos(next) + } + + const onUp = () => { + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + }, []) + + if (!editMode) { + return null + } + + return ( + <div + className="absolute z-50 flex w-[26rem] max-w-[calc(100%-2rem)] flex-col rounded-xl border border-(--ui-stroke-secondary) bg-popover text-popover-foreground shadow-2xl [-webkit-app-region:no-drag]" + ref={cardRef} + style={pos ? { left: pos.x, top: pos.y } : { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' }} + > + {/* Header doubles as the drag handle (Panel-style title + actions). */} + <header + className="flex shrink-0 cursor-grab select-none items-start justify-between gap-3 px-4 pb-2 pt-3 active:cursor-grabbing" + onPointerDown={startMove} + > + <div className="min-w-0"> + <h2 className="text-sm font-semibold text-foreground">{t.zones.editTitle}</h2> + <p className="text-xs text-muted-foreground/80"> + {t.zones.editHint}{' '} + {toggleCombo && ( + <kbd className="rounded border border-(--ui-stroke-secondary) bg-foreground/5 px-1 font-mono text-[10px]"> + {formatCombo(toggleCombo)} + </kbd> + )} + </p> + </div> + <div className="flex shrink-0 items-center gap-1.5" onPointerDown={e => e.stopPropagation()}> + <Button onClick={resetLayoutTree} size="sm" variant="ghost"> + {t.zones.reset} + </Button> + <Button onClick={() => $layoutEditMode.set(false)} size="sm" variant="outline"> + {t.common.done} + </Button> + </div> + </header> + <div className="px-4 pb-4"> + <LayoutPicker /> + </div> + </div> + ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx new file mode 100644 index 000000000000..ddb466b9f265 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx @@ -0,0 +1,81 @@ +/** + * Layout tree renderer (root). + * + * - `split` -> flex row/column; 1px seams between siblings double as resize + * sashes (the seam IS the boundary — junction-owned, never doubled). See + * tree-split.tsx. + * - `group` -> a ZONE: header strip (tabs when stacked, minimize chevron) + + * the active pane's content, resolved from the contribution registry + * (`area: 'panes'`). Empty zones exist only in editor-authored trees. See + * tree-group.tsx. + * + * Dragging is FancyZones-style (drag-session.ts): the LAYOUT STAYS FIXED and + * every zone lights up as a whole-region drop target; dropping moves the pane + * into that zone (joining its tab stack). Structure changes (splitting/merging/ + * resizing zones) belong to the zone editor, not the drag. + * + * This file owns only the composition: the recursive tree, the narrow-viewport + * overlays, the edit palette, and the zone editor. The pieces live in sibling + * modules — track-model (sizing), drag-session (drag), tree-split / tree-group + * (nodes), layout-picker + edit-bar (edit mode), narrow-overlays. + */ + +import { useStore } from '@nanostores/react' +import { type ReactNode, useEffect } from 'react' + +import { useLayoutEditHotkey } from '../../edit-mode' +import { publishWorkspaceGeometry } from '../../geometry' +import { $layoutTree, trackActiveTreeGroup } from '../store' +import { ZoneEditor } from '../zone-editor' + +import { TreeEditBar } from './edit-bar' +import { NarrowOverlays } from './narrow-overlays' +import { TreeNode } from './tree-node' + +export function LayoutTreeRoot({ children }: { children?: ReactNode }) { + const tree = useStore($layoutTree) + + useLayoutEditHotkey(true) + // Track the interacted zone so ⌘W closes the right tab even when nothing is + // DOM-focused. + useEffect(trackActiveTreeGroup, []) + // Publish --workspace-left/right so chrome (titlebar title) aligns to the + // main pane's geometry in plain CSS. + useEffect(publishWorkspaceGeometry, []) + + if (!tree) { + return null + } + + return ( + <div className="relative flex min-h-0 min-w-0 flex-1"> + {/* ZonesOverlay::GetAnimationAlpha ramp: clamp(t / 200ms, 0.001, 1). */} + <style>{`@keyframes hermes-zone-fade { from { opacity: 0.001 } to { opacity: 1 } }`}</style> + {/* THE SEAM INVARIANT: boundaries are drawn by the tree (one sash + hairline per seam) — content mounted in a zone must not paint its + own edge chrome. App components (asides, the shadcn sidebar) carry + edge borders + inset highlights for the OLD shell's geometry; this + neutralizes all of them at the zone boundary, for every current and + future pane, instead of per-pane class surgery. */} + <style>{` + [data-tree-group] :is(aside, [data-slot=sidebar]) { + border-left-width: 0; + border-right-width: 0; + box-shadow: none; + } + /* Old-shell titlebar BANDS (chat's session header et al size to + --titlebar-height, which is 0 inside zones): a zero-height band is + non-functional but still paints its border-b — a stray hairline + doubling the zone's top seam. Remove the band entirely. */ + [data-tree-group] header[class*="h-(--titlebar-height)"] { + display: none; + } + `}</style> + <TreeNode node={tree} root rootRow={tree.type === 'split' && tree.orientation === 'row'} /> + <NarrowOverlays /> + <TreeEditBar /> + <ZoneEditor /> + {children} + </div> + ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/layout-picker.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/layout-picker.tsx new file mode 100644 index 000000000000..95b303501f51 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/layout-picker.tsx @@ -0,0 +1,203 @@ +/** + * Layout picker — the preset card grid inside the edit palette. Thumbnails + * are a miniature render of each preset's layout tree; clicking a card + * applies it, and "Save current arrangement" captures the live tree as a + * user preset. The "New grid layout" button opens the zone editor. + */ + +import { useStore } from '@nanostores/react' +import { type ReactNode, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Input } from '@/components/ui/input' +import { useContributions } from '@/contrib/react/use-contributions' +import type { Contribution } from '@/contrib/types' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' + +import type { LayoutNode } from '../model' +import { isLayoutNode } from '../model' +import { applyLayoutPreset, deleteUserPreset, isUserPreset, LAYOUTS_AREA, saveCurrentLayoutAs } from '../presets' +import { $activePresetId } from '../store' +import { $zoneEditorOpen } from '../zone-editor' + +/** Miniature render of a layout tree — the preset card thumbnail. */ +function TreeThumbnail({ node }: { node: LayoutNode }) { + if (node.type === 'group') { + return ( + // currentColor-derived fill: light zones on dark themes, dark zones on + // light — legible everywhere without leaning on the accent. + <div + className="min-h-0 min-w-0 flex-1 rounded-[2px]" + style={{ background: 'color-mix(in srgb, currentColor 16%, transparent)' }} + /> + ) + } + + return ( + <div className={cn('flex min-h-0 min-w-0 flex-1 gap-px', node.orientation === 'row' ? 'flex-row' : 'flex-col')}> + {node.children.map((child, i) => ( + <div + className="flex min-h-0 min-w-0" + key={child.id} + style={{ flex: `${node.weights[i]} ${node.weights[i]} 0px` }} + > + <TreeThumbnail node={child} /> + </div> + ))} + </div> + ) +} + +/** Small-caps section heading — the app's SidebarPanelLabel voice. */ +function PickerSectionLabel({ children }: { children: ReactNode }) { + return ( + <span className="text-[0.6rem] font-semibold uppercase tracking-[0.16em] text-(--ui-text-quaternary)"> + {children} + </span> + ) +} + +function PresetCard({ preset }: { preset: Contribution }) { + const { t } = useI18n() + const activeId = useStore($activePresetId) + const tree = isLayoutNode(preset.data) ? preset.data : null + + if (!tree) { + return null + } + + const active = preset.id === activeId + + return ( + <div className="group/preset relative"> + <button + className={cn( + 'flex w-full flex-col gap-1.5 rounded-lg border p-1.5 text-left transition-colors', + active + ? 'border-(--ui-accent) bg-(--ui-row-active-background)' + : 'border-(--ui-stroke-secondary) hover:border-(--ui-stroke-primary) hover:bg-(--ui-row-hover-background)' + )} + onClick={() => applyLayoutPreset(preset.id, tree)} + type="button" + > + <div className="flex h-12 w-full"> + <TreeThumbnail node={tree} /> + </div> + <span + className={cn('truncate text-[0.68rem] font-medium', active ? 'text-foreground' : 'text-muted-foreground/80')} + > + {preset.title ?? preset.id} + </span> + </button> + {isUserPreset(preset.id) && ( + <button + aria-label={t.zones.deletePreset(preset.title ?? preset.id)} + // Hover-reveal (opacity, not display) — stays laid out + clickable, + // appears on card hover or keyboard focus. + className="absolute right-1 top-1 z-10 grid size-5 place-items-center rounded-md bg-(--ui-bg-elevated) text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 group-hover/preset:opacity-100" + onClick={() => deleteUserPreset(preset.id)} + onPointerDown={e => e.stopPropagation()} + type="button" + > + <Codicon name="close" size="0.7rem" /> + </button> + )} + </div> + ) +} + +export function LayoutPicker() { + const { t } = useI18n() + const presets = useContributions(LAYOUTS_AREA) + const [name, setName] = useState('') + const [saving, setSaving] = useState(false) + + const templates = presets.filter(p => !isUserPreset(p.id) && isLayoutNode(p.data)) + const custom = presets.filter(p => isUserPreset(p.id) && isLayoutNode(p.data)) + + const commitSave = () => { + if (!name.trim()) { + return + } + + saveCurrentLayoutAs(name) + setName('') + setSaving(false) + } + + return ( + <div className="flex flex-col gap-4"> + <section className="flex flex-col gap-2"> + <PickerSectionLabel>{t.zones.templates}</PickerSectionLabel> + <div className="grid grid-cols-4 gap-2"> + {templates.map(preset => ( + <PresetCard key={`${preset.source ?? 'core'}:${preset.id}`} preset={preset} /> + ))} + </div> + </section> + + <section className="flex flex-col gap-2"> + <PickerSectionLabel>{t.zones.custom}</PickerSectionLabel> + {custom.length > 0 && ( + <div className="grid grid-cols-4 gap-2"> + {custom.map(preset => ( + <PresetCard key={`${preset.source ?? 'core'}:${preset.id}`} preset={preset} /> + ))} + </div> + )} + <Button + className="h-8 w-full justify-center gap-1.5 border border-dashed border-(--ui-stroke-secondary) text-muted-foreground hover:border-(--ui-stroke-primary) hover:text-foreground" + onClick={() => $zoneEditorOpen.set(true)} + size="sm" + variant="ghost" + > + <Codicon name="add" size="0.875rem" /> + {t.zones.newGridLayout} + </Button> + </section> + + {/* Save-current lives behind a reveal so the raw input doesn't clash + with the card grid until it's actually needed. */} + {saving ? ( + <form + className="flex items-center gap-1.5" + onSubmit={e => { + e.preventDefault() + commitSave() + }} + > + <Input + autoFocus + className="h-7 flex-1 text-xs" + onChange={e => setName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Escape') { + setSaving(false) + setName('') + } + }} + placeholder={t.zones.nameLayoutPlaceholder} + value={name} + /> + <Button disabled={!name.trim()} size="sm" type="submit" variant="outline"> + {t.common.save} + </Button> + <Button onClick={() => setSaving(false)} size="sm" variant="ghost"> + {t.common.cancel} + </Button> + </form> + ) : ( + <button + className="flex items-center gap-1.5 self-start text-xs text-muted-foreground/80 transition-colors hover:text-foreground" + onClick={() => setSaving(true)} + type="button" + > + <Codicon name="save" size="0.8125rem" /> + {t.zones.saveCurrentAs} + </button> + )} + </div> + ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx new file mode 100644 index 000000000000..3d75388d77e9 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx @@ -0,0 +1,146 @@ +/** + * Narrow-viewport edge overlays — the tree's take on the app's hover-reveal + * collapse. Collapsible panes leave the grid below the sidebar-collapse + * breakpoint; an edge strip (hover) or PANE_TOGGLE_REVEAL_EVENT (⌘B / ⌘G / + * titlebar toggles route here on narrow) slides the pane OVER the layout + * instead of squeezing it. Event reveals pin; hover reveals follow the mouse. + */ + +import { useStore } from '@nanostores/react' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { ContribBoundary } from '@/contrib/react/boundary' +import { useContributions } from '@/contrib/react/use-contributions' +import type { Contribution } from '@/contrib/types' +import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' +import { cn } from '@/lib/utils' + +import { PANE_TOGGLE_REVEAL_EVENT } from '../..' +import { allPaneIds } from '../model' +import { $hiddenTreePanes, $layoutTree, $narrowViewport } from '../store' + +import { paneChrome } from './track-model' + +export function NarrowOverlays() { + const narrow = useStore($narrowViewport) + const tree = useStore($layoutTree) + const panes = useContributions('panes') + const hiddenPanes = useStore($hiddenTreePanes) + const [reveal, setReveal] = useState<{ id: string; pinned: boolean } | null>(null) + + // Own an Escape layer only while something is revealed, so Escape closes the + // overlay only when it's the top layer (never under a dialog / edit mode). + const revealActive = reveal !== null + useEffect(() => (revealActive ? pushEscapeLayer(ESCAPE_PRIORITY.narrowOverlay) : undefined), [revealActive]) + + const inTree = useMemo(() => new Set(tree ? allPaneIds(tree) : []), [tree]) + + const collapsibles = useMemo( + () => panes.filter(p => paneChrome(p).collapsible && inTree.has(p.id) && !hiddenPanes.has(p.id)), + [panes, inTree, hiddenPanes] + ) + + const collapsiblesRef = useRef(collapsibles) + collapsiblesRef.current = collapsibles + + // ⌘B / ⌘G's narrow branch dispatches the app's toggle-reveal event with the + // REAL pane id — accept those via each contribution's revealAliases. + useEffect(() => { + if (!narrow) { + setReveal(null) + + return + } + + const onToggle = (event: Event) => { + const detail = (event as CustomEvent<{ id?: string; mode?: 'close' | 'open' | 'toggle' }>).detail + const id = detail?.id + + if (!id) { + return + } + + const match = collapsiblesRef.current.find(p => p.id === id || paneChrome(p).revealAliases?.includes(id)) + + if (!match) { + return + } + + // `open`/`close` are explicit intents (programmatic reveal, titlebar show); + // `toggle` (default) is the ⌘B/⌘G flip. + const mode = detail?.mode ?? 'toggle' + setReveal(current => { + if (mode === 'open') { + return { id: match.id, pinned: true } + } + + if (mode === 'close') { + return current?.id === match.id ? null : current + } + + return current?.id === match.id && current.pinned ? null : { id: match.id, pinned: true } + }) + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)) { + return + } + + event.preventDefault() + setReveal(null) + } + + window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) + window.addEventListener('keydown', onKeyDown) + + return () => { + window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) + window.removeEventListener('keydown', onKeyDown) + } + }, [narrow]) + + if (!narrow || collapsibles.length === 0) { + return null + } + + const sideOf = (c: Contribution) => (paneChrome(c).placement === 'left' ? 'left' : 'right') + const revealed = reveal ? collapsibles.find(p => p.id === reveal.id) : undefined + const sides = [...new Set(collapsibles.map(sideOf))] + + return ( + <> + {/* Hover-intent strips on each edge that has a collapsed pane. */} + {sides.map(side => ( + <div + className={cn('absolute inset-y-0 z-30 w-1.5', side === 'left' ? 'left-0' : 'right-0')} + key={side} + onMouseEnter={() => { + const first = collapsibles.find(p => sideOf(p) === side) + + if (first) { + setReveal(current => (current?.pinned ? current : { id: first.id, pinned: false })) + } + }} + /> + ))} + + {revealed && ( + <div + className={cn( + 'absolute inset-y-0 z-40 flex flex-col overflow-hidden bg-(--ui-sidebar-surface-background) shadow-2xl', + sideOf(revealed) === 'left' + ? 'left-0 border-r border-(--ui-stroke-secondary)' + : 'right-0 border-l border-(--ui-stroke-secondary)' + )} + onMouseLeave={() => setReveal(current => (current?.pinned ? current : null))} + // Match the pane's docked width (sessions ~237px, files its rail + // width) instead of a fat fixed 20rem — capped for tiny screens. + style={{ width: `min(${(revealed.data as { width?: string } | undefined)?.width ?? '18rem'}, 85vw)` }} + > + <ContribBoundary id={revealed.id}>{revealed.render?.()}</ContribBoundary> + </div> + )} + </> + ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts new file mode 100644 index 000000000000..d5e5b9afe43b --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts @@ -0,0 +1,268 @@ +/** + * The TRACK MODEL — how a layout node resolves its size along a split axis. + * + * A node is a FIXED track when it resolves to a CSS length (sidebars keep + * their declared size) and a FLEX track when it doesn't (weight-shared + * leftover). Everything here is pure geometry over the layout tree + the + * live pane contributions; the React split renderer reads it per render. + */ + +import type * as React from 'react' + +import type { Contribution } from '@/contrib/types' + +import type { GroupNode, LayoutNode } from '../model' +import { allPaneIds } from '../model' + +import type { DoubleTapContext } from './drag-session' + +export const MIN_PANE_PX = 80 + +/** Optional CSS sizing a pane contributes (`data.width` / `data.minWidth`…). + * Applied to the pane's GROUP along the axis of the split that contains it — + * the same semantics as the app's `Pane width/minWidth/maxWidth` props: + * a `width`/`height` makes the zone a FIXED track (sidebar-style — it keeps + * its size and the weighted zones absorb the rest); without one the zone + * shares leftover space by weight. */ +export interface PaneSizing { + width?: string + height?: string + minWidth?: string + maxWidth?: string + minHeight?: string + maxHeight?: string +} + +/** Chrome behavior flags a pane contributes. Read via `paneChrome`. */ +interface PaneChrome { + /** Leaves the grid on narrow viewports; revealed as an edge overlay. */ + collapsible?: boolean + /** Extra ids accepted from PANE_TOGGLE_REVEAL_EVENT (the real app's pane + * ids, e.g. `chat-sidebar` for `sessions`). */ + revealAliases?: string[] + placement?: string + /** No Close in the tab menu — the one surface the app can't lose (the + * main workspace). Session tiles share `placement: 'main'` but close. */ + uncloseable?: boolean + /** Wrap this pane's TAB (e.g. in a domain context menu — a session tile's + * pin/branch/rename/archive/delete). The wrapper must render `tab` as its + * interactive child; the zone's own strip menu still owns non-tab space. */ + tabWrap?: (tab: React.ReactElement) => React.ReactNode + /** Override this pane's TAB drag (a session tab drags like a sidebar row — + * stack / split / composer-link — not the generic pane move). Given the + * tab's tap (activate) + double-tap (hide header) so those gestures survive. + * Returns whether it took the drag; `false` (or absent) defers to + * `startPaneDrag` — e.g. the workspace tab on a fresh draft, nothing to link. */ + tabDrag?: (event: React.PointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) => boolean + /** Suppress the zone header while THIS pane is active — full-page views + * (artifacts/skills/plugin pages) are not tab-able surfaces. The flag is + * live: the workspace contribution re-registers it on route changes. */ + headerVeto?: boolean +} + +export const paneChrome = (c: Contribution | undefined) => (c?.data ?? {}) as PaneChrome + +/** Resolve a computed style length ("237px" / "none" / "auto") to px. */ +export function computedPx(value: string, fallback: number): number { + const n = Number.parseFloat(value) + + return Number.isFinite(n) ? n : fallback +} + +/** Resolve an AUTHORED CSS length ("237px", "38vh", "clamp(18rem,36vw,32rem)") + * to px by measuring a probe inside `container` — handles every unit and + * math function the browser does. */ +export function resolveCssPx(container: HTMLElement, css: number | string, horizontal: boolean): number | null { + if (typeof css === 'number') { + return css + } + + const probe = document.createElement('div') + probe.style.position = 'absolute' + probe.style.visibility = 'hidden' + probe.style.pointerEvents = 'none' + + if (horizontal) { + probe.style.width = css + } else { + probe.style.height = css + } + + container.appendChild(probe) + const rect = probe.getBoundingClientRect() + probe.remove() + const px = horizontal ? rect.width : rect.height + + return Number.isFinite(px) && px > 0 ? px : null +} + +/** Everything fixed-track resolution needs about the current view state. */ +export interface TrackContext { + paneFor: (id: string) => Contribution | undefined + paneGone: (id: string) => boolean + overrides: Record<string, { widthOverride?: number; heightOverride?: number }> +} + +/** A group's panes that are actually on screen (not hidden / narrow-collapsed + * / unregistered). The one place the "shown" filter lives. */ +export const shownPaneIds = (group: GroupNode, ctx: TrackContext): string[] => + group.panes.filter(id => !ctx.paneGone(id)) + +/** max() of the defined CSS lengths (deduped); undefined when none — the + * largest-tenant basis a fixed stack and its clamps both size from. */ +export const cssMax = (values: (string | null | undefined)[]): string | undefined => { + const unique = [...new Set(values.filter((v): v is string => Boolean(v)))] + + return unique.length === 0 ? undefined : unique.length === 1 ? unique[0] : `max(${unique.join(', ')})` +} + +/** + * THE TRACK MODEL. A node's size along `axis` is FIXED when it resolves to a + * CSS length, and FLEX (weight-shared leftover) when null: + * + * - zone: the max() of its shown panes' declared `width`/`height` (a live px + * override from a sash drag wins) — sidebars keep their size, main flexes, + * and the zone never resizes when tabs switch or a drop fronts a pane. + * - split ALONG the axis: the sum of its visible children — fixed only when + * every child is (one flex child makes the run flex). + * - split ACROSS the axis: the max of its visible fixed children (flex + * children just stretch to the container); flex only when none are fixed. + * + * This is how "two right sidebars over a terminal row" sizes itself from its + * content (237px, or 474px when review is visible) instead of taking a + * fraction of the window. + */ +/** A minimized zone IS its strip: the vertical rail (row) / header (column) + * are both 28px thick. */ +export const MINIMIZED_TRACK = '1.75rem' + +export function fixedTrackSize(node: LayoutNode, axis: 'row' | 'column', ctx: TrackContext): string | null { + if (node.type === 'group') { + // Ancestor splits must size a minimized zone as its strip, not as its + // panes' declared widths — otherwise the outer track keeps reserving the + // full sidebar width and the collapsed rail floats in a dead column. + if (node.minimized) { + return MINIMIZED_TRACK + } + + const overrideKey = axis === 'row' ? 'widthOverride' : 'heightOverride' + + const declared = (id: string) => { + const sizing = (ctx.paneFor(id)?.data ?? {}) as PaneSizing + const css = (axis === 'row' ? sizing.width : sizing.height) ?? null + const override = ctx.overrides[id]?.[overrideKey] + + // An override only refines a pane that DECLARES a size along this axis + // (sash drags write overrides to fixed zones only). One without a + // declaration is stale data from another surface — honoring it would + // turn a flex-at-heart zone (main!) into a fixed track and hand the + // whole leftover to the run's absorber. + if (css !== null && override !== undefined) { + return `${override}px` + } + + return css + } + + // Which zones are FIXED tracks: + // - a MAIN-bearing zone (workspace/tile stacked in) is flex-at-heart — + // mixing a sidebar pane into it (files fronted in the Focus mono-stack) + // must NOT snap the whole zone to sidebar width; + // - any other zone stays fixed as long as SOME tenant declares a size — + // dropping a size-less pane (the terminal has height but no width) + // into the 237px files sidebar must not balloon it to a flex track. + const ids = shownPaneIds(node, ctx) + const sizes = ids.map(declared) + const declaredSizes = sizes.filter((size): size is string => size !== null) + + if (declaredSizes.length === 0) { + return null + } + + if (sizes.length !== declaredSizes.length && ids.some(id => paneChrome(ctx.paneFor(id)).placement === 'main')) { + return null + } + + // A STACK sizes to its LARGEST tenant (CSS max()), never the active tab: + // dropping a pane into a zone — the drop fronts it — or switching tabs + // must not resize the container (dropping sessions into a wider fixed + // zone used to snap the whole zone down to sidebar width). + return cssMax(declaredSizes) ?? null + } + + const visible = node.children.filter(child => !subtreeGone(child, ctx)) + const sizes = visible.map(child => fixedTrackSize(child, axis, ctx)) + + if (node.orientation === axis) { + if (sizes.length === 0 || sizes.some(size => size === null)) { + return null + } + + return sizes.length === 1 ? sizes[0] : `calc(${sizes.join(' + ')})` + } + + // Across the axis a flex child just stretches; the fixed ones set the size. + return cssMax(sizes) ?? null +} + +/** True when every pane in the subtree is hidden/narrow-collapsed. */ +export function subtreeGone(node: LayoutNode, ctx: TrackContext): boolean { + const ids = allPaneIds(node) + + return ids.length > 0 && ids.every(ctx.paneGone) +} + +/** + * Which chrome toggle owns a root-row child — SEMANTIC, not positional: + * ⌘B is the sessions/nav column (any `placement: 'left'` pane) wherever a + * flip or drag puts it; ⌘J is every other side column. `null` = contains + * the main zone, never side-collapsed. This is what keeps the titlebar + * toggles and reveals 100% main-compatible through ⌘\ flips. + */ +export function rootChildSide( + child: LayoutNode, + paneFor: (id: string) => Contribution | undefined +): 'left' | 'right' | null { + const placements = allPaneIds(child).map(id => paneChrome(paneFor(id)).placement) + + if (placements.includes('main')) { + return null + } + + return placements.includes('left') ? 'left' : 'right' +} + +/** + * The FIXED zone that owns `edge` of this subtree along `axis` — the zone a + * sash on that boundary actually resizes (dragging the seam between main and + * a nested right section resizes the section's edge sidebar, VS Code-style). + */ +export function edgeFixedZone( + node: LayoutNode, + edge: 'start' | 'end', + axis: 'row' | 'column', + ctx: TrackContext +): GroupNode | null { + if (node.type === 'group') { + return fixedTrackSize(node, axis, ctx) !== null ? node : null + } + + const visible = node.children.filter(child => !subtreeGone(child, ctx)) + + if (node.orientation === axis) { + const child = edge === 'start' ? visible[0] : visible[visible.length - 1] + + return child ? edgeFixedZone(child, edge, axis, ctx) : null + } + + // Cross-axis: every child touches the edge — the first fixed one owns it. + for (const child of visible) { + const zone = edgeFixedZone(child, edge, axis, ctx) + + if (zone) { + return zone + } + } + + return null +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx new file mode 100644 index 000000000000..8988e374a751 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -0,0 +1,676 @@ +/** + * Group node renderer — a ZONE: header strip (tabs when stacked, minimize + * chevron) + the active pane's content, resolved from the contribution + * registry (`area: 'panes'`). Empty zones exist only in editor-authored + * trees (drop targets until the first structural op prunes them). + * + * Dragging is FancyZones-style (drag-session.ts): the layout stays fixed and + * every zone lights up as a whole-region drop target. Right-click opens the + * contextual zone menu (split/move + header/minimize toggles). + */ + +import { useStore } from '@nanostores/react' +import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu' +import { DecodeText } from '@/components/ui/decode-text' +import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance' +import { + PANE_TAB_STRIP_LINE, + PANE_TAB_STRIP_LINE_LEFT, + PANE_TAB_STRIP_LINE_RIGHT, + PaneTab, + PaneTabLabel +} from '@/components/ui/pane-tab' +import { ContribBoundary } from '@/contrib/react/boundary' +import { useContributions } from '@/contrib/react/use-contributions' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' + +import { $layoutEditMode } from '../../edit-mode' +import { useWindowControlsOverlap } from '../../geometry' +import type { DropPosition, GroupNode, RootEdge } from '../model' +import { adjacentGroup } from '../model' +import { + $dropHint, + $hiddenTreePanes, + $layoutTree, + $narrowViewport, + $treeDragging, + activateTreePane, + closeTreePane, + collapseTreePane, + dismissTreePane, + isCollapsePane, + moveTreePane, + restoreTreePane, + SESSION_TILE_DRAG, + setTreeGroupHeaderHidden, + splitTreeZone, + toggleTreeGroupMinimized +} from '../store' + +import { type DoubleTapContext, startPaneDrag } from './drag-session' +import { paneChrome } from './track-model' + +/** A directional action in the zone menu (computed per group state). */ +interface ZoneMenuDirection { + side: RootEdge + label: string + run: () => void +} + +const DIRECTION_ORDER: readonly RootEdge[] = ['right', 'bottom', 'left', 'top'] +const DIRECTION_ARROW: Record<RootEdge, string> = { bottom: '↓', left: '←', right: '→', top: '↑' } + +/** Right-click zone menu: directional actions + header toggle + minimize. + * The directions are CONTEXTUAL (computed by TreeGroup): a stacked group + * offers "Split <dir>" (carve a new zone with the clicked pane — VS Code + * split-and-move in one gesture); a single-pane group offers "Move <dir>" + * into the zone actually sitting on that side — directions with no visible + * neighbor aren't offered, so no action ever appears to do nothing. */ +function ZoneMenu({ + children, + closable, + minimizable = true, + directions, + headerHidden, + minimized, + nodeId +}: { + children: ReactNode + /** The pane the menu closes (the right-clicked chip / the active pane); + * undefined = not closable (the main zone). */ + closable?: () => string | undefined + /** False for the zone hosting the uncloseable workspace — collapsing the + * MAIN pane strands the app behind a strip. */ + minimizable?: boolean + directions: ZoneMenuDirection[] + headerHidden?: boolean + minimized?: boolean + nodeId: string +}) { + const { t } = useI18n() + + return ( + <ContextMenu> + <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> + <ContextMenuContent> + {directions.map(direction => ( + <ContextMenuItem key={direction.side} onSelect={direction.run}> + {direction.label} + </ContextMenuItem> + ))} + <ContextMenuItem onSelect={() => setTreeGroupHeaderHidden(nodeId, !headerHidden)}> + {headerHidden ? t.zones.showHeader : t.zones.hideHeader} + </ContextMenuItem> + {minimizable && ( + <ContextMenuItem onSelect={() => toggleTreeGroupMinimized(nodeId, !minimized)}> + {minimized ? t.zones.restore : t.zones.minimize} + </ContextMenuItem> + )} + {/* Resolved at render: the menu mounts on open, after the right-click + set menuPane — so an uncloseable target hides the item instead + of offering a dead action. */} + {closable?.() !== undefined && ( + <ContextMenuItem + onSelect={() => { + const paneId = closable?.() + + if (paneId) { + closeTreePane(paneId) + } + }} + > + {t.common.close} + </ContextMenuItem> + )} + </ContextMenuContent> + </ContextMenu> + ) +} + +export function TreeGroup({ + node, + parentAxis, + railSide = 'left' +}: { + node: GroupNode + parentAxis?: 'column' | 'row' + railSide?: 'left' | 'right' +}) { + const { t } = useI18n() + const ref = useRef<HTMLDivElement>(null) + const stripRef = useRef<HTMLDivElement>(null) + // The chip under the last right-click — the pane the zone menu's Split + // actions carry into the new zone (header background = the active pane). + // STATE, not a ref: the menu items (incl. Close's visibility) are JSX + // evaluated during THIS component's render — a ref write on right-click + // doesn't re-render, so the menu showed the PREVIOUS target's items (Close + // missing on an inactive tile tab whose zone-active was the uncloseable + // workspace). + const [menuPane, setMenuPane] = useState<string | undefined>(undefined) + const panes = useContributions('panes') + // Coarse drag flag only (set once at drag start/end). The per-frame drop + // HINT lives in ZoneDropOverlay so a moving pointer re-renders the tiny + // overlay, not every zone's header/body (and not the menuDirections walk). + const dragging = useStore($treeDragging) + const editMode = useStore($layoutEditMode) + const wcOverlap = useWindowControlsOverlap(ref, true) + + const hiddenPanes = useStore($hiddenTreePanes) + const narrow = useStore($narrowViewport) + + const paneFor = (id: string) => panes.find(p => p.id === id) + + // Unregistered (plugin not loaded), chrome-toggled-off, and narrow-collapsed + // panes drop out of the header; the active pane falls back to the first + // shown one (render-side — the tree keeps `active`). + // Edit mode forces toggle-hidden panes visible so they can be rearranged + // (mirrors tree-split's paneGone) — restores itself on exit. + const paneShown = (id: string) => + Boolean(paneFor(id)) && (editMode || !hiddenPanes.has(id)) && !(narrow && paneChrome(paneFor(id)).collapsible) + + const shown = node.panes.filter(paneShown) + const activeId = shown.includes(node.active) ? node.active : (shown[0] ?? node.active) + const active = paneFor(activeId) + const isEmpty = node.panes.length === 0 + + // ONE header style: the app's compact pane-header. DEFAULT is contextual — + // a single pane isn't a "tab", so its header auto-hides; a stack shows its + // chips. EXCEPTIONS force a lone pane to keep its header (tab + close X): + // - a TILE (closeable, placement 'main' — a session/page split), else a + // tile in its own zone is unclosable (the "3rd tile has no tab" trap); + // - a TOOL PANEL (terminal/logs — a collapse pane) dragged out of the main + // stack, else it's a dead zone with no tab to grab or ✕ to close. + // The uncloseable workspace and side chrome (sessions/files) keep the clean + // no-tab default. Double-click toggles it either way; a minimized group + // always shows its header (it IS the header). + const forceLoneHeader = + shown.some(id => { + const chrome = paneChrome(paneFor(id)) + + return !chrome.uncloseable && chrome.placement === 'main' + }) || + (shown.length === 1 && isCollapsePane(shown[0])) + + // A full-page view (headerVeto) suppresses the strip while it's the active + // pane — a page is not a tab-able surface; the bar returns with the chat. + const headerHidden = paneChrome(active).headerVeto || (node.headerHidden ?? (shown.length <= 1 && !forceLoneHeader)) + + // A group collapses ALONG its parent split's axis. In a row that means the + // WIDTH collapses — a full-width horizontal header would strand a tall + // empty column, so the minimized form is a narrow vertical rail instead + // (tabs reading top-to-bottom). In a column (stacked zones) the horizontal + // header IS the collapsed form, exactly as before. + const verticalCollapse = Boolean(node.minimized) && parentAxis === 'row' && !isEmpty + const headerVisible = !isEmpty && !verticalCollapse && (Boolean(node.minimized) || !headerHidden) + + // Drag handles preventDefault pointerdown (no native dblclick), so the + // header + chips share a synthesized double-tap: restore if collapsed + // (undoing the first tap's minimize toggle) and hide the chrome. + const hideHeaderDoubleTap: DoubleTapContext = { + key: `hide-header-${node.id}`, + onDoubleTap: () => { + toggleTreeGroupMinimized(node.id, false) + setTreeGroupHeaderHidden(node.id, true) + } + } + + const dirWord: Record<RootEdge, string> = { + bottom: t.zones.dirDown, + left: t.zones.dirLeft, + right: t.zones.dirRight, + top: t.zones.dirUp + } + + // Zone-menu directions, contextual to this group's state: + // - stacked panes -> "Split <dir>": carve a new zone on that side with the + // right-clicked chip's pane in it (split + move, one gesture); + // - a single pane -> "Move <dir>": join the zone visually adjacent on that + // side (splitting here would only make an invisible empty zone). Sides + // with no visible neighbor are omitted entirely. + const tree = useStore($layoutTree) + + const menuDirections: ZoneMenuDirection[] = + shown.length > 1 + ? DIRECTION_ORDER.map(side => ({ + side, + label: `${t.zones.split(dirWord[side])} ${DIRECTION_ARROW[side]}`, + run: () => splitTreeZone(node.id, side, menuPane ?? activeId) + })) + : DIRECTION_ORDER.flatMap(side => { + const neighbor = tree ? adjacentGroup(tree, node.id, side, g => g.panes.some(paneShown)) : null + + if (!neighbor || neighbor.id === node.id) { + return [] + } + + return [ + { + side, + label: `${t.zones.move(dirWord[side])} ${DIRECTION_ARROW[side]}`, + run: () => moveTreePane(activeId, { groupId: neighbor.id, pos: 'center' }) + } + ] + }) + + // Close targets the right-clicked chip (falling back to the active pane); + // only panes that declare `uncloseable` (the main workspace) are exempt. + const closable = () => { + const paneId = menuPane ?? activeId + + return paneChrome(paneFor(paneId)).uncloseable ? undefined : paneId + } + + // The zone hosting the uncloseable workspace never minimizes — collapsing + // MAIN strands the whole app behind a strip. + const minimizable = !shown.some(id => paneChrome(paneFor(id)).uncloseable) + + // Tab ✕: a tool panel (terminal/logs) is REMOVED from the layout (comes back + // via its toggle); everything else routes through its Close (a session tile + // closes the session, a store-bound pane collapses). + const closeTab = (paneId: string) => (isCollapsePane(paneId) ? dismissTreePane(paneId) : closeTreePane(paneId)) + + // Collapse/restore a tool panel (or plain minimize elsewhere) — the header + // chevron + tap gesture, routed so ⌃`/the titlebar toggle stay truthful. + const toggleCollapse = () => (node.minimized ? restoreTreePane(activeId) : collapseTreePane(activeId)) + + // Same menu on the header strip and the edit veil — one prop bag. + const zoneMenu = { + closable, + directions: menuDirections, + headerHidden, + minimizable, + minimized: node.minimized, + nodeId: node.id + } + + // NO body double-click toggle: virtualized content (the thread) recreates + // its nodes between clicks, so the gesture was hopelessly unreliable. The + // bar's lifecycle is explicit instead — gaining a tab sticky-shows it + // (insertAtGroup pins headerHidden false), the main tab's context menu + // hides it, and full-page views veto it via paneChrome.headerVeto. + + return ( + <div + className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-(--ui-bg-editor)" + data-tree-group={node.id} + // Advertises the visible tab strip so panes can drop their own + // self-naming labels (see [data-pane-self-label] in styles.css). + data-zone-header={headerVisible || undefined} + ref={ref} + style={wcOverlap ? { paddingTop: wcOverlap.y + wcOverlap.height } : undefined} + > + {wcOverlap && ( + <div + aria-hidden="true" + className="pointer-events-none absolute z-10 [-webkit-app-region:drag]" + style={{ height: wcOverlap.height, left: wcOverlap.x, top: wcOverlap.y, width: wcOverlap.width }} + /> + )} + + {/* Minimized in a ROW: a narrow vertical rail — same PaneTab shell as + the horizontal strip, just `vertical`. Click a tab to restore + + activate; click anywhere else on the rail to restore. */} + {verticalCollapse && ( + <ZoneMenu {...zoneMenu}> + <div + className={cn( + 'flex h-full w-7 shrink-0 cursor-pointer select-none flex-col items-stretch bg-(--pane-tab-strip-bg) [--pane-tab-strip-bg:var(--theme-card-seed)]', + // Strip line faces the content the zone collapsed away from. + railSide === 'right' ? PANE_TAB_STRIP_LINE_LEFT : PANE_TAB_STRIP_LINE_RIGHT + )} + onClick={() => restoreTreePane(activeId)} + title={t.zones.restore} + > + <div + className="flex min-h-0 flex-col overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" + role="tablist" + > + {shown.map(paneId => { + const closeable = !paneChrome(paneFor(paneId)).uncloseable + const title = paneFor(paneId)?.title ?? paneId + + return ( + <PaneTab + // Match the horizontal minimized strip: no tab is "active" + // while collapsed (there's no content surface to merge into). + aria-selected={paneId === activeId} + data-tree-tab={paneId} + key={paneId} + onClick={event => { + event.stopPropagation() + restoreTreePane(paneId) + }} + onClose={closeable ? () => closeTab(paneId) : undefined} + role="tab" + side={railSide} + vertical + > + <PaneTabLabel>{title}</PaneTabLabel> + </PaneTab> + ) + })} + </div> + </div> + </ZoneMenu> + )} + + {/* Header: the file-preview tab strip (PaneTab), one shared component. */} + {headerVisible && ( + <ZoneMenu {...zoneMenu}> + <div + // Active = sidebar surface (merges into body). Strip = + // `--theme-card-seed` (VS Code `tab.inactiveBackground`). Line = + // PANE_TAB_STRIP_LINE; active tab cuts through it. + // data-zone-tabstrip: a drop over here STACKS (drag-session reads it). + className={cn( + 'group/pane-header relative flex h-7 shrink-0 select-none bg-(--pane-tab-strip-bg) [-webkit-app-region:no-drag] [--pane-tab-active-bg:var(--ui-sidebar-surface-background)] [--pane-tab-strip-bg:var(--theme-card-seed)]', + PANE_TAB_STRIP_LINE + )} + data-zone-tabstrip={node.id} + onContextMenu={e => { + setMenuPane( + (e.target as HTMLElement).closest('[data-tree-tab]')?.getAttribute('data-tree-tab') ?? undefined + ) + }} + onPointerDown={e => + // Tap the header to collapse to it / expand back — the DetailPane + // / sidebar-section gesture (never for the main zone). Double-tap + // hides the header entirely. Drag still moves the pane. + startPaneDrag( + activeId, + e, + () => minimizable && toggleCollapse(), + undefined, + hideHeaderDoubleTap, + active?.title ?? activeId + ) + } + ref={stripRef} + style={{ cursor: 'grab' }} + > + <div + className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" + role="tablist" + > + {shown.map(paneId => { + const isActive = paneId === activeId && !node.minimized + const chrome = paneChrome(paneFor(paneId)) + const closeable = !chrome.uncloseable + const title = paneFor(paneId)?.title ?? paneId + + const tab = ( + <PaneTab + active={isActive} + aria-selected={isActive} + data-tree-tab={paneId} + key={paneId} + onClose={closeable ? () => closeTab(paneId) : undefined} + onPointerDown={e => { + // Tabs ACTIVATE (restoring a collapsed group). Minimize + // lives on the chevron / single-pane label — overloading + // the active tab made double-click a minimize/restore/hide + // lottery. + const onTap = () => { + if (node.minimized) { + restoreTreePane(paneId) + } + + activateTreePane(node.id, paneId) + } + + // Claim the press so the STRIP's own pane-drag handler + // (parent onPointerDown) can't also fire. startPaneDrag + // does this internally; the session drag (shared with + // sidebar rows) doesn't, so do it here for both paths. + if (e.button === 0) { + e.preventDefault() + e.stopPropagation() + } + + // A pane may own its tab drag (a session tab speaks the + // session drop language — link/stack/split); `false` defers + // to the generic pane move (the workspace tab on a fresh + // draft has no session to link). + if (!chrome.tabDrag?.(e, onTap, hideHeaderDoubleTap)) { + startPaneDrag( + paneId, + e, + onTap, + stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined, + hideHeaderDoubleTap, + title + ) + } + }} + role="tab" + style={{ cursor: 'grab' }} + > + <PaneTabLabel>{title}</PaneTabLabel> + </PaneTab> + ) + + // A pane may wrap ITS tab in a domain menu (session verbs on a + // tile tab); the wrapper needs the key since it's the root. + return <Fragment key={paneId}>{chrome.tabWrap ? chrome.tabWrap(tab) : tab}</Fragment> + })} + </div> + {minimizable && ( + <button + aria-label={node.minimized ? t.zones.restore : t.zones.minimize} + className="mx-1 grid size-5 shrink-0 place-items-center self-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 group-hover/pane-header:opacity-100" + onClick={toggleCollapse} + onPointerDown={e => e.stopPropagation()} + type="button" + > + <Codicon name={node.minimized ? 'chevron-down' : 'chevron-up'} size="0.75rem" /> + </button> + )} + <StripDropCaret groupId={node.id} stripRef={stripRef} /> + </div> + </ZoneMenu> + )} + + {/* Body: the active pane's contributed content, or the empty zone. */} + {!node.minimized && ( + <div className="relative min-h-0 min-w-0 flex-1 overflow-auto"> + {isEmpty ? ( + <div className="grid h-full place-items-center"> + {/* Same decode primitive as the CONNECTING boot overlay. */} + <DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" /> + </div> + ) : active?.render ? ( + <ContribBoundary id={active.id}>{active.render()}</ContribBoundary> + ) : ( + <div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">{t.zones.missingPane(activeId)}</div> + )} + </div> + )} + + {/* Edit-mode veil: the BODY is a drag handle for the active pane. It + starts below the header so tabs/headers stay directly interactive + (drag any tab, right-click for the zone menu). */} + {editMode && !dragging && !isEmpty && !node.minimized && ( + <ZoneMenu {...zoneMenu}> + <div + // z-50: pane CONTENT may carry its own stacked chrome (the + // terminal rail is z-40) — the edit veil must cover all of it. + // The scrim mixes the accent over the CHROME BG (not transparent) + // so it properly dims content in dark themes instead of leaving a + // barely-tinted wash; the light blur reads as "edit mode" the same + // way the zone editor's backdrop does. + className="absolute inset-x-0 bottom-0 z-50 flex cursor-grab items-center justify-center outline-1 -outline-offset-2 outline-dashed backdrop-blur-[2px]" + onPointerDown={e => startPaneDrag(activeId, e, undefined, undefined, undefined, active?.title ?? activeId)} + style={{ + top: headerVisible ? 28 : 0, + background: + 'color-mix(in srgb, var(--ui-accent) 6%, color-mix(in srgb, var(--ui-bg-chrome) 55%, transparent))', + outlineColor: 'color-mix(in srgb, var(--ui-accent) 55%, transparent)' + }} + > + <span className="flex max-w-[calc(100%-1rem)] items-center gap-1.5 rounded-md border border-(--ui-stroke-secondary) bg-popover px-2 py-1 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-(--ui-text-secondary)"> + <Codicon className="shrink-0" name="gripper" size="0.8125rem" /> + <span className="min-w-0 truncate">{active?.title ?? activeId}</span> + </span> + </div> + </ZoneMenu> + )} + + {/* FancyZones drop overlay — its own component so the per-frame drop + hint re-renders only this (tiny) node, not the whole zone. */} + <ZoneDropOverlay node={node} /> + </div> + ) +} + +// --------------------------------------------------------------------------- +// Tab-strip insertion caret +// --------------------------------------------------------------------------- + +/** + * The insertion divider for a stack drop: a 2px vertical line at the slot the + * dragged tab will land in (before `stack.before`, or after the last tab). + * Absolute over the strip — pure overlay, zero layout shift. #000 on light, + * #FFF on dark. Split out so per-pointermove `$dropHint` churn re-renders + * only this node (same isolation contract as ZoneDropOverlay). + */ +function StripDropCaret({ groupId, stripRef }: { groupId: string; stripRef: RefObject<HTMLDivElement | null> }) { + const hint = useStore($dropHint) + const strip = stripRef.current + const stack = hint?.groupId === groupId ? hint.stack : undefined + + if (stack === undefined || !strip) { + return null + } + + // Slot x: the before-tab's left edge, or the last tab's right edge. + const tabs = [...strip.querySelectorAll<HTMLElement>('[data-tree-tab]')] + const target = stack.before ? tabs.find(el => el.dataset.treeTab === stack.before) : tabs.at(-1) + + if (!target) { + return null + } + + const stripRect = strip.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const x = (stack.before ? targetRect.left : targetRect.right) - stripRect.left + + // A short centered tick (~60% of the tab), not a full-height wall — reads + // as an insertion point between labels, browser-tab style. + return ( + <span + aria-hidden + className="pointer-events-none absolute z-50 w-px -translate-x-1/2 bg-black dark:bg-white" + style={{ + height: targetRect.height * 0.6, + left: x, + top: targetRect.top - stripRect.top + targetRect.height * 0.2 + }} + /> + ) +} + +// --------------------------------------------------------------------------- +// FancyZones drop overlay +// --------------------------------------------------------------------------- + +/** Overlay entry fade. FancyZones ships 200ms (FADE_IN_DURATION_MILLIS in + * zones-engine); on a drag that starts under the cursor that ramp reads as + * lag, so the sheets snap in far faster — same softening, instant feel. */ +const OVERLAY_FADE_MS = 80 + +/** Sheet inset from the zone edge (px). */ +const REGION_PAD = 6 + +/** The sheet's box per drop position — longhand insets so CSS transitions can + * interpolate the px↔% change: the target GLIDES between the full zone and + * the hovered half instead of snapping (VS Code dock preview). */ +const REGION: Record<DropPosition, CSSProperties> = { + bottom: { bottom: REGION_PAD, left: REGION_PAD, right: REGION_PAD, top: '50%' }, + center: { bottom: REGION_PAD, left: REGION_PAD, right: REGION_PAD, top: REGION_PAD }, + left: { bottom: REGION_PAD, left: REGION_PAD, right: '50%', top: REGION_PAD }, + right: { bottom: REGION_PAD, left: '50%', right: REGION_PAD, top: REGION_PAD }, + top: { bottom: '50%', left: REGION_PAD, right: REGION_PAD, top: REGION_PAD } +} + +/** + * The FancyZones drop overlay for one zone. Split out of TreeGroup so the + * per-pointermove `$dropHint` churn re-renders only this lightweight node — + * the zone's header, body, and menu-direction walk stay put during a drag. + * + * ONE dashed sheet per zone (DROP_SHEET_CLASS — the composer drop and the zone + * targets speak identically): a quiet outline over every eligible zone, + * accent-lit over the target, morphing to the hovered half for an edge split. + */ +function ZoneDropOverlay({ node }: { node: GroupNode }) { + const dragging = useStore($treeDragging) + const hint = useStore($dropHint) + + if (dragging === null) { + return null + } + + // A session drag (sidebar row) reuses this exact overlay — over ANY zone + // now (stack into its tabs / split its edges); only a CHAT zone's center is + // a link-to-chat (the composer overlay owns that visual). + const sessionDrag = dragging === SESSION_TILE_DRAG + const chatZone = node.panes.some(p => p === 'workspace' || p.startsWith('session-tile:')) + + const isDragSource = node.panes.includes(dragging) + + // The source zone, when it holds only the dragged pane, has nothing to drop. + if (isDragSource && node.panes.length === 1) { + return null + } + + const primary = hint?.groupId === node.id + + // Hovering the target's TAB STRIP: the insertion caret (StripDropCaret) + // owns the affordance — the zone sheet stands down so the two never stack. + if (primary && hint?.stack !== undefined) { + return null + } + + const active = hint?.groupIds?.includes(node.id) ?? false + const multi = (hint?.groupIds?.length ?? 0) > 1 + // Sub-positions only exist for a single-zone target (a Shift-span merges). + const pos = primary && !multi ? (hint?.pos ?? 'center') : 'center' + // Session drag over a CHAT zone's CENTER: the "link to chat" overlay inside + // the surface (ChatDropOverlay — the same sheet) owns that region; this sheet + // fades out so the two never stack. A non-chat zone's center has no chat to + // link, so it shows the normal stack sheet. Edges act like a tab. + const centerLink = sessionDrag && primary && pos === 'center' && chatZone + + return ( + <div + className="pointer-events-none absolute inset-0 z-40" + style={{ animation: `hermes-zone-fade ${OVERLAY_FADE_MS}ms linear both` }} + > + <div + className={cn( + DROP_SHEET_CLASS, + // Transition ONLY the box + colors. `transition-all` also animated + // backdrop-filter, and a blur interpolating while the insets glide + // re-blurs half a zone every frame — the single most expensive + // paint in the whole drag. + 'absolute transition-[top,right,bottom,left,background-color,border-color,opacity] duration-150 ease-out', + // Blur only the live target — idle outlines must not fog the app. + active && !centerLink && DROP_SHEET_BLUR_CLASS, + centerLink && 'opacity-0' + )} + style={{ + ...REGION[pos], + // Accent over a card wash so the fill dims content on dark themes + // (a bare accent alpha disappears there). + background: active + ? 'color-mix(in srgb, var(--ui-accent) 18%, color-mix(in srgb, var(--dt-card) 55%, transparent))' + : 'color-mix(in srgb, var(--ui-accent) 5%, color-mix(in srgb, var(--dt-card) 25%, transparent))', + borderColor: `color-mix(in srgb, var(--ui-accent) ${active ? 75 : 28}%, transparent)` + }} + /> + </div> + ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx new file mode 100644 index 000000000000..16c27304c596 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx @@ -0,0 +1,33 @@ +import type { LayoutNode } from '../model' + +import { TreeGroup } from './tree-group' +import { TreeSplit } from './tree-split' + +/** Dispatch a layout node to its renderer — the split/group recursion point. + * `root` marks the tree's top split (side collapse applies only there). + * `rootRow` marks the row split that owns the side columns — usually the root + * itself, but in a column-root layout (Terminal deck, Quad) it's the row + * child holding sessions/workspace/files. Side collapse (⌘B/⌘J) applies here. + * `parentAxis` is the containing split's orientation — a group collapses + * ALONG that axis, so it picks the minimized form (row → vertical rail, + * column → horizontal header). `railSide` is which half of that row the + * child sits in — the rail's divider stroke faces the content side. */ +export function TreeNode({ + node, + parentAxis, + railSide, + root, + rootRow +}: { + node: LayoutNode + parentAxis?: 'column' | 'row' + railSide?: 'left' | 'right' + root?: boolean + rootRow?: boolean +}) { + return node.type === 'split' ? ( + <TreeSplit node={node} root={root} rootRow={rootRow} /> + ) : ( + <TreeGroup node={node} parentAxis={parentAxis} railSide={railSide} /> + ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx new file mode 100644 index 000000000000..f6fe8e926cd1 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx @@ -0,0 +1,540 @@ +/** + * Split node renderer — a flex row/column whose 1px seams double as resize + * sashes (the seam IS the boundary — junction-owned, never doubled). Sizing + * is the TRACK MODEL (track-model.ts): fixed tracks keep their declared size, + * flex tracks share the leftover by weight, and an all-fixed run lets its + * last track absorb the slack (VS Code style). + */ + +import { useStore } from '@nanostores/react' +import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useSyncExternalStore } from 'react' + +import { useContributions } from '@/contrib/react/use-contributions' +import { cn } from '@/lib/utils' +import { $paneStates, type PaneStateSnapshot, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes' + +import { $layoutEditMode } from '../../edit-mode' +import type { LayoutNode, SplitNode } from '../model' +import { allPaneIds } from '../model' +import { + $collapsedTreeSides, + $hiddenTreePanes, + $narrowViewport, + persistTree, + presetSplitWeights, + setTreeSplitWeights +} from '../store' + +import { + computedPx, + cssMax, + edgeFixedZone, + fixedTrackSize, + MIN_PANE_PX, + paneChrome, + type PaneSizing, + resolveCssPx, + rootChildSide, + shownPaneIds, + subtreeGone, + type TrackContext +} from './track-model' +import { TreeNode } from './tree-node' + +/** + * The size overrides for a fixed set of panes, referentially stable until one + * of THEM changes. Sash drags churn `$paneStates` every frame; subscribing the + * whole map would re-render every split — this narrows each split to its own + * subtree via a signature-gated snapshot. + */ +function useSubtreeOverrides(paneIds: readonly string[]): TrackContext['overrides'] { + const key = paneIds.join(',') + const cache = useRef<{ sig: string; value: Record<string, PaneStateSnapshot> }>({ sig: '\0', value: {} }) + + const snapshot = useCallback(() => { + const all = $paneStates.get() + const sig = paneIds.map(id => `${id}:${all[id]?.widthOverride ?? ''}:${all[id]?.heightOverride ?? ''}`).join('|') + + if (cache.current.sig !== sig) { + cache.current = { sig, value: Object.fromEntries(paneIds.flatMap(id => (all[id] ? [[id, all[id]]] : []))) } + } + + return cache.current.value + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]) + + return useSyncExternalStore(cb => $paneStates.listen(cb), snapshot, snapshot) +} + +export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boolean; rootRow?: boolean }) { + const containerRef = useRef<HTMLDivElement>(null) + const panes = useContributions('panes') + const hiddenPanes = useStore($hiddenTreePanes) + const narrow = useStore($narrowViewport) + // Scoped to THIS subtree's panes: a sash drag writes size overrides on every + // pointermove, but only the splits whose subtree actually resized should + // re-render — not every split in the tree. + const overrides = useSubtreeOverrides(useMemo(() => allPaneIds(node), [node])) + const editMode = useStore($layoutEditMode) + const collapsedSides = useStore($collapsedTreeSides) + const horizontal = node.orientation === 'row' + const axis = node.orientation + + // When the root is a column (Terminal deck, Quad), the root ROW — the one + // the side-collapse system operates on — is a row child containing main. + // Propagate `rootRow` to that child so its `semanticSides` fires. + const childRootRow = (child: LayoutNode): boolean => { + if (!root || horizontal) { + return false + } + + if (child.type !== 'split' || child.orientation !== 'row') { + return false + } + + return allPaneIds(child).some(id => paneChrome(paneFor(id)).placement === 'main') + } + + // A pane leaves the grid when its contribution isn't registered (yet) — a + // runtime plugin's pane collapses until the plugin loads, then appears; no + // placeholder flash — when a chrome toggle hides it, or when the viewport + // is narrow and the pane is collapsible (edge overlay instead). + const paneFor = (id: string) => panes.find(p => p.id === id) + + // Layout-edit mode forces toggle-hidden panes (terminal off, review/preview + // closed) visible so they're rearrangeable — only truly-absent (unregistered) + // or narrow-collapsed panes stay gone. Restores itself on exit (render-only). + const paneGone = (id: string) => + !paneFor(id) || (!editMode && hiddenPanes.has(id)) || (narrow && Boolean(paneChrome(paneFor(id)).collapsible)) + + const trackCtx: TrackContext = { paneFor, paneGone, overrides } + + // Chrome-toggle collapse: a subtree whose every pane is gone renders + // display:none (content stays MOUNTED — toggling back is instant), and its + // siblings absorb the space. Narrow-collapse UNMOUNTS instead, so the edge + // overlay owns the single live instance of the pane's content. + // EMPTY zones only exist in editor-authored trees (normalize prunes them on + // every structural op) — they take space in edit mode as drop targets. + const isEmptyZone = (child: LayoutNode) => child.type === 'group' && child.panes.length === 0 + const isCollapsed = (child: LayoutNode) => subtreeGone(child, trackCtx) || (isEmptyZone(child) && !editMode) + + // Min/max clamps come from a direct GROUP child's panes (the same clamps + // the app's Pane props express) — but ONLY when they can speak for the + // zone: a fixed track (pure sidebar stack) or a single-pane zone. A sidebar + // pane fronted in a mixed flex stack must not cap it. A fixed STACK + // aggregates its panes' clamps (largest-tenant semantics, mirroring the + // max() track basis) — the active tab's caps must never resize the zone. + const sizingFor = (child: LayoutNode, track: string | null): PaneSizing | null => { + if (child.type !== 'group' || child.panes.length === 0) { + return null + } + + const shownIds = shownPaneIds(child, trackCtx) + + if (track === null && shownIds.length !== 1) { + return null + } + + if (shownIds.length <= 1) { + return (paneFor(shownIds[0])?.data as PaneSizing | undefined) ?? null + } + + // Fixed STACK: floors take the largest declared min; caps stay unbounded + // unless EVERY pane declares one (a single uncapped tenant uncaps the + // zone). Same largest-tenant basis as the track size — never per-tab. + const all = shownIds.map(id => (paneFor(id)?.data ?? {}) as PaneSizing) + + const cap = (pick: (s: PaneSizing) => string | undefined) => + all.every(pick) ? cssMax(all.map(pick)) : undefined + + return { + minWidth: cssMax(all.map(s => s.minWidth)), + maxWidth: cap(s => s.maxWidth), + minHeight: cssMax(all.map(s => s.minHeight)), + maxHeight: cap(s => s.maxHeight) + } + } + + // Sashes pair each visible child with its nearest visible PREVIOUS sibling + // (`aIndex`/`bIndex`), not blindly `i-1`/`i` — a collapsed zone in between + // (e.g. the closed preview pane parked between main and the right rail) + // must not swallow the seam its visible neighbors share. + const startSash = useCallback( + (aIndex: number, bIndex: number, e: ReactPointerEvent<HTMLDivElement>) => { + const container = containerRef.current + + if (!container || e.button !== 0) { + return + } + + e.preventDefault() + + const handle = e.currentTarget + const { pointerId } = e + const rect = container.getBoundingClientRect() + const totalPx = horizontal ? rect.width : rect.height + const totalWeight = node.weights.reduce((a, b) => a + b, 0) || 1 + const pxPerWeight = totalPx / totalWeight + const start = horizontal ? e.clientX : e.clientY + const restoreCursor = document.body.style.cursor + const restoreSelect = document.body.style.userSelect + + // Each side of the seam resolves to a RESIZE TARGET: a fixed zone (the + // sash writes its px override — sidebar semantics) or the flex run + // (the sash writes weights). Sizes/clamps read from the live DOM of + // whichever element actually owns the boundary. + const sizeOf = (el: HTMLElement) => { + const r = el.getBoundingClientRect() + + return horizontal ? r.width : r.height + } + + const sideFor = (child: LayoutNode, wrapper: HTMLElement, edge: 'start' | 'end') => { + const fixed = fixedTrackSize(child, axis, trackCtx) !== null + const zone = fixed ? edgeFixedZone(child, edge, axis, trackCtx) : null + const zoneEl = zone ? container.querySelector<HTMLElement>(`[data-tree-group="${zone.id}"]`) : null + // Clamps live on the zone's split-child WRAPPER (where we render them). + const el = zoneEl?.parentElement ?? wrapper + const cs = window.getComputedStyle(el) + + return { + // EVERY shown pane of the zone: the zone's track is the max() of its + // panes' sizes, so the sash writes the same px to all of them — + // writing only the active pane would leave the zone pinned at a + // larger sibling's width. + paneIds: zone ? shownPaneIds(zone, trackCtx) : [], + fixed: Boolean(zone), + size: sizeOf(zoneEl ?? wrapper), + min: Math.max(MIN_PANE_PX, computedPx(horizontal ? cs.minWidth : cs.minHeight, 0)), + max: computedPx(horizontal ? cs.maxWidth : cs.maxHeight, Number.POSITIVE_INFINITY) + } + } + + const kidA = container.children[aIndex] as HTMLElement | undefined + const kidB = container.children[bIndex] as HTMLElement | undefined + + if (!kidA || !kidB) { + return + } + + const a = sideFor(node.children[aIndex], kidA, 'end') + const b = sideFor(node.children[bIndex], kidB, 'start') + const a0px = a.fixed ? a.size : sizeOf(kidA) + const b0px = b.fixed ? b.size : sizeOf(kidB) + const lo = Math.max(a.min - a0px, b0px - b.max) + const hi = Math.min(a.max - a0px, b0px - b.min) + + const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride + + try { + handle.setPointerCapture?.(pointerId) + } catch { + // Synthetic events. + } + + document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize' + document.body.style.userSelect = 'none' + + const onMove = (ev: PointerEvent) => { + const shiftPx = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start)) + + if (a.fixed) { + a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx))) + } + + if (b.fixed) { + b.paneIds.forEach(id => setOverride(id, Math.round(b0px - shiftPx))) + } + + if (!a.fixed && !b.fixed) { + const weights = [...node.weights] + // Convert the CLAMPED pixel sizes back to weights so the persisted + // weights always agree with what's on screen. + weights[aIndex] = (a0px + shiftPx) / pxPerWeight + weights[bIndex] = (b0px - shiftPx) / pxPerWeight + setTreeSplitWeights(node.id, weights) + } + } + + const cleanup = () => { + document.body.style.cursor = restoreCursor + document.body.style.userSelect = restoreSelect + + try { + handle.releasePointerCapture?.(pointerId) + } catch { + // Mirror. + } + + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', cleanup, true) + window.removeEventListener('pointercancel', cleanup, true) + persistTree() + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', cleanup, true) + window.addEventListener('pointercancel', cleanup, true) + }, + // trackCtx is derived state rebuilt per render; the drag captures it once. + // eslint-disable-next-line react-hooks/exhaustive-deps + [axis, editMode, horizontal, node.children, node.id, node.weights, hiddenPanes, narrow, overrides, panes] + ) + + // Double-click a sash: every neighbor returns to its DEFAULT size. + // - fixed zones (sidebar stacks): clear the drag override -> the declared + // width (237px etc.) comes back; + // - flex zones fronted by a size-declaring pane (a sidebar in a mixed + // stack): pin the weight so the zone lands EXACTLY on that size; + // - everything else: the preset's weights for this split (rearranging + // panes keeps the applied preset's split ids), else even distribution. + const resetBoundary = useCallback( + (aIndex: number, bIndex: number) => { + const container = containerRef.current + + if (!container) { + return + } + + const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride + + for (const [child, edge] of [ + [node.children[aIndex], 'end'], + [node.children[bIndex], 'start'] + ] as const) { + const zone = edgeFixedZone(child, edge, axis, trackCtx) + + for (const paneId of zone ? shownPaneIds(zone, trackCtx) : []) { + setOverride(paneId, undefined) + } + } + + const preset = presetSplitWeights(node.id, node.weights.length) + const weights = preset ?? [...node.weights] + + const rect = container.getBoundingClientRect() + const totalPx = horizontal ? rect.width : rect.height + let pinned = false + + for (const i of [aIndex, bIndex]) { + const child = node.children[i] + + // Fixed tracks size themselves from the declared width (override + // cleared above) — weights only matter for FLEX zones. + if (child.type !== 'group' || fixedTrackSize(child, axis, trackCtx) !== null) { + continue + } + + // The zone's natural default = the largest size any of its panes + // declares along this axis (a sessions+terminal stack is still a + // 237px sidebar at heart, whichever chip is fronted). + let px: number | null = null + + for (const paneId of shownPaneIds(child, trackCtx)) { + const sizing = (paneFor(paneId)?.data ?? {}) as PaneSizing + const css = horizontal ? sizing.width : sizing.height + const resolved = css ? resolveCssPx(container, css, horizontal) : null + + if (resolved !== null) { + px = Math.max(px ?? 0, resolved) + } + } + + if (px === null || px <= 0 || px >= totalPx) { + continue + } + + const others = weights.reduce((sum, w, j) => (j === i ? sum : sum + w), 0) + + if (others > 0) { + weights[i] = (px * others) / (totalPx - px) + pinned = true + } + } + + setTreeSplitWeights(node.id, !preset && !pinned ? weights.map(() => 1) : weights) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [axis, editMode, horizontal, node.children, node.id, node.weights, hiddenPanes, narrow, overrides, panes] + ) + + // A run of ONLY fixed tracks can't fill the container (grow-0 all around + // leaves dead space — e.g. terminal + logs split into two 38vh zones with + // the rail above them collapsed). The LAST visible track absorbs the + // leftover, VS Code style. + const isMinimized = (child: LayoutNode) => child.type === 'group' && Boolean(child.minimized) + + // SEMANTIC side collapse (titlebar toggles / ⌘B / ⌘J): at the ROOT row, + // ⌘B owns the sessions column and ⌘J the other side columns — by pane + // placement, NOT position, so a ⌘\ flip moves the columns without + // rewiring the toggles (main parity). In edit mode sides stay visible. + // `rootRow` covers both a row root (Default, Focus) and a row nested inside + // a column root (Terminal deck, Quad) — wherever the side columns live. + const semanticSides = rootRow && horizontal && collapsedSides.size > 0 && !editMode + + const sideGone = (i: number) => { + if (!semanticSides) { + return false + } + + const side = rootChildSide(node.children[i], paneFor) + + return side !== null && collapsedSides.has(side) + } + + // One pass per child: collapse/minimize state, resolved fixed track, clamps, + // and narrow-unmount flag. fixedTrackSize + subtreeGone each re-walk the + // subtree, so resolve them ONCE here instead of per read below. + const tracks = node.children.map((child, i) => { + const minimized = isMinimized(child) + const collapsed = isCollapsed(child) || sideGone(i) + const track = minimized || collapsed ? null : fixedTrackSize(child, axis, trackCtx) + const sizing = minimized || collapsed ? null : sizingFor(child, track) + // Narrow-collapse UNMOUNTS (the edge overlay owns the live instance) — but + // only for panes the breakpoint collapsed, not ones a chrome toggle hid. + const narrowCollapsed = narrow && collapsed && allPaneIds(child).some(id => !hiddenPanes.has(id)) + + return { child, collapsed, minimized, narrowCollapsed, sizing, track } + }) + + const growable = tracks.map((_, i) => i).filter(i => !tracks[i].collapsed && !tracks[i].minimized) + const allFixed = growable.length > 0 && growable.every(i => tracks[i].track !== null) + const absorberIndex = allFixed ? growable[growable.length - 1] : -1 + + // Weights are RATIOS, but CSS flex-grow is absolute: a run whose grows sum + // below 1 fills only that fraction of the leftover (normalize's flatten + // scales weights into the parent slot — a dock-split nested into an + // existing column can leave grow 0.5, i.e. dead space). Renormalize the + // flex run so its grows always sum to 1. + const flexTotal = growable.reduce((sum, i) => sum + (tracks[i].track === null ? node.weights[i] : 0), 0) + const grow = (i: number) => node.weights[i] / (flexTotal || 1) + + // The seam partner for a visible child: the nearest VISIBLE previous + // sibling. Collapsed zones (a hidden pane parked mid-row) are skipped, so + // their visible neighbors keep a shared, draggable boundary. + const seamPartner = (i: number): number => { + for (let j = i - 1; j >= 0; j--) { + if (!tracks[j].collapsed) { + return j + } + } + + return -1 + } + + // Which half of this row a visible child sits in — a minimized zone's rail + // hugs the app edge it collapsed toward, so its divider stroke must face + // the content side (left rail → stroke right, right rail → stroke left). + const visibleOrder = tracks.map((t, j) => (t.collapsed ? -1 : j)).filter(j => j >= 0) + + const railSideFor = (i: number): 'left' | 'right' => { + const pos = visibleOrder.indexOf(i) + + return pos >= 0 && (pos + 0.5) / visibleOrder.length > 0.5 ? 'right' : 'left' + } + + return ( + <div + className={cn('flex min-h-0 min-w-0 flex-1', horizontal ? 'flex-row' : 'flex-col')} + data-tree-split={node.id} + ref={containerRef} + > + {tracks.map(({ child, collapsed, minimized, narrowCollapsed, sizing, track }, i) => { + const partner = collapsed ? -1 : seamPartner(i) + + return ( + <div + className="relative flex min-h-0 min-w-0" + key={child.id} + style={ + collapsed + ? { display: 'none' } + : minimized + ? { flex: '0 0 auto' } + : { + // One flexbox formula for everything: a sized zone is + // grow-0 shrink-1 from its preferred basis (it yields + // gracefully on tight windows, floored by min-width); + // everything else splits the leftover by weight. In an + // all-fixed run the last track grows into the leftover. + flex: track + ? `${i === absorberIndex ? 1 : 0} 1 ${track}` + : `${grow(i)} ${grow(i)} 0px`, + // Pane-declared clamps apply along THIS split's axis only + // (a rail's width clamp shouldn't constrain its height). + // The absorber drops its max clamp — it exists to fill + // the leftover, and clamping would recreate the gap. + minWidth: (horizontal && sizing?.minWidth) || 0, + maxWidth: horizontal && i !== absorberIndex ? sizing?.maxWidth : undefined, + minHeight: (!horizontal && sizing?.minHeight) || 0, + maxHeight: horizontal || i === absorberIndex ? undefined : sizing?.maxHeight + } + } + > + {partner >= 0 && ( + <Sash + disabled={minimized || tracks[partner].minimized} + horizontal={horizontal} + onDoubleClick={() => resetBoundary(partner, i)} + onPointerDown={e => startSash(partner, i, e)} + /> + )} + {!narrowCollapsed && ( + <TreeNode + node={child} + parentAxis={axis} + railSide={horizontal ? railSideFor(i) : undefined} + rootRow={rootRow || childRootRow(child)} + /> + )} + </div> + ) + })} + </div> + ) +} + +function Sash({ + disabled, + horizontal, + onDoubleClick, + onPointerDown +}: { + disabled?: boolean + horizontal: boolean + onDoubleClick?: () => void + onPointerDown: (e: ReactPointerEvent<HTMLDivElement>) => void +}) { + return ( + <div + className={cn( + 'group absolute z-20 [-webkit-app-region:no-drag]', + horizontal ? 'inset-y-0 left-0 w-[9px] -translate-x-1/2' : 'inset-x-0 top-0 h-[9px] -translate-y-1/2', + disabled ? 'pointer-events-none' : horizontal ? 'cursor-col-resize' : 'cursor-row-resize' + )} + onDoubleClick={disabled ? undefined : onDoubleClick} + onPointerDown={disabled ? undefined : onPointerDown} + role="separator" + > + {/* Persistent hairline: same token as PaneShell's divider sash + (--ui-stroke-secondary) so every seam — vertical or horizontal — + reads identically. */} + <span + className={cn( + 'absolute bg-(--ui-stroke-secondary)', + horizontal ? 'inset-y-0 left-1/2 w-px -translate-x-1/2' : 'inset-x-0 top-1/2 h-px -translate-y-1/2' + )} + /> + {!disabled && ( + <span + className={cn( + 'absolute bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100', + horizontal + ? 'inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2' + : 'inset-x-0 top-1/2 h-(--vscode-sash-hover-size,0.25rem) -translate-y-1/2' + )} + /> + )} + </div> + ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts new file mode 100644 index 000000000000..2ec8e72da067 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -0,0 +1,1237 @@ +/** + * Layout tree store: one persisted tree replaces paneStates side/band + * overrides. The DEFAULT tree is declared by the app root (like config); + * the persisted tree is the user's customization; reset returns to default. + */ + +import { atom, computed } from 'nanostores' + +import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants' +import { setPluginEnabled } from '@/contrib/plugins-store' +import { registry } from '@/contrib/registry' +import { translateNow } from '@/i18n' +import { readJson, readKey, writeJson, writeKey } from '@/lib/storage' +import { notify } from '@/store/notifications' +import { clearAllPaneSizeOverrides } from '@/store/panes' +import { isSecondaryWindow } from '@/store/windows' + +import { + allPaneIds, + type DropPosition, + findGroup, + findGroupOfPane, + groupLeafIds, + insertAtGroup, + isLayoutNode, + type LayoutNode, + mergeZonesWithPane as mergeZonesWithPaneOp, + mirrorTreeHorizontal, + movePane as movePaneOp, + normalize, + removePane, + reorderPaneInGroup as reorderPaneInGroupOp, + type RootEdge, + setActivePane as setActivePaneOp, + setGroupHeaderHidden as setGroupHeaderHiddenOp, + setGroupMinimized, + setSplitWeights as setSplitWeightsOp, + splitGroupZone as splitGroupZoneOp, + type SplitNode +} from './model' +import { rootChildSide } from './renderer/track-model' + +// v2: v1 trees were saved against placeholder panes with index-order zone +// assignment (chat could land in a corner cell). Retire them wholesale. +const STORAGE_KEY = 'hermes.desktop.layoutTree.v2' + +writeKey('hermes.desktop.layoutTree.v1', null) + +let defaultTree: LayoutNode | null = null + +function loadPersisted(): LayoutNode | null { + const parsed = readJson<unknown>(STORAGE_KEY) + + // Canonicalize on load: strips stale attributes older code persisted + // (e.g. explicit headerHidden on lone-pane zones) and re-flattens. + return isLayoutNode(parsed) ? normalize(parsed) : null +} + +function persist(tree: LayoutNode | null) { + // A secondary window (single-chat pop-out) shares the origin's localStorage; + // writing its stripped-down DEFAULT tree back would wipe the primary's layout. + if (isSecondaryWindow()) { + return + } + + writeJson(STORAGE_KEY, tree) +} + +/** The live tree (null until a default is declared). A secondary window ignores + * the persisted (primary) layout and boots to the default — nothing but its + * own routed session. */ +export const $layoutTree = atom<LayoutNode | null>(isSecondaryWindow() ? null : loadPersisted()) + +/** + * Which layout preset the current tree came from; `'custom'` after the user + * rearranges anything. Drives the picker's active highlight. + */ +export const $activePresetId = atom<string>(readKey('hermes.desktop.layoutPreset.active') ?? 'default') + +export function markActivePreset(id: string) { + $activePresetId.set(id) + writeKey('hermes.desktop.layoutPreset.active', id) +} + +/** Pane id being dragged (tree drag session), null when idle. Also set to the + * SESSION_TILE_DRAG sentinel while a sidebar session is dragged over the tree, + * so the SAME zone overlay lights up (see session-tile-drop-bridge). */ +export const $treeDragging = atom<string | null>(null) + +/** Sentinel `$treeDragging` value for a session (not a pane) drag — the zone + * overlay renders its normal targets, scoped to session-hosting zones. */ +export const SESSION_TILE_DRAG = '__session-tile-drag__' + +/** + * Panes hidden by app chrome toggles (titlebar sidebar / right-sidebar + * buttons). The tree KEEPS the zone and its mounted content; a zone whose + * every pane is hidden collapses to nothing until a toggle brings it back. + * Not persisted here — each binding's store owns persistence. + */ +export const $hiddenTreePanes = atom<ReadonlySet<string>>(new Set()) + +/** Add/remove `item` in a readonly set, returning a fresh set — or null when + * membership already matches `present` (so callers can early-out on a no-op). */ +function toggledSet<T>(set: ReadonlySet<T>, item: T, present: boolean): Set<T> | null { + if (set.has(item) === present) { + return null + } + + const next = new Set(set) + + if (present) { + next.add(item) + } else { + next.delete(item) + } + + return next +} + +export function setTreePaneHidden(paneId: string, hidden: boolean) { + const next = toggledSet($hiddenTreePanes.get(), paneId, hidden) + + if (!next) { + return + } + + $hiddenTreePanes.set(next) + + // Unhiding is an intent to SEE the pane — front it in its group. + if (!hidden) { + revealTreePane(paneId) + } +} + +/** + * CLOSE — the tab context menu's "Close". Two routes: + * - a registered closer (core panes whose visibility an app store owns: + * review/terminal/preview/sessions) closes through that store, so the + * titlebar/statusbar toggles stay truthful; + * - everything else (plugin panes, unbound core panes) is DISMISSED: removed + * from the tree and remembered so adoption doesn't re-add it. Reveal + * intent (a preview target, ⌘G) or a layout reset un-dismisses. + */ +const DISMISSED_KEY = 'hermes.desktop.dismissedPanes.v1' + +function loadDismissed(): ReadonlySet<string> { + return new Set(readJson<string[]>(DISMISSED_KEY) ?? []) +} + +export const $dismissedPanes = atom<ReadonlySet<string>>(loadDismissed()) + +function saveDismissed(next: ReadonlySet<string>) { + $dismissedPanes.set(next) + writeJson(DISMISSED_KEY, next.size === 0 ? null : [...next]) +} + +function setDismissed(paneId: string, dismissed: boolean) { + const next = toggledSet($dismissedPanes.get(), paneId, dismissed) + + if (next) { + saveDismissed(next) + } +} + +const paneClosers: Record<string, () => void> = {} +const paneOpeners: Record<string, () => void> = {} + +/** Route a pane's Close through the app store that owns its visibility. */ +export function registerPaneCloser(paneId: string, close: () => void) { + paneClosers[paneId] = close +} + +/** + * Route a pane's "show it" intent through the app store that owns its + * visibility — the mirror of `registerPaneCloser`, so a preset can reveal a + * toggle-gated pane (e.g. the terminal, whose visibility ⌃`/`$terminalTakeover` + * owns) while the toggle stays truthful. Only panes that opt in via + * `data.revealOnPreset` are opened on preset apply. + */ +export function registerPaneOpener(paneId: string, open: () => void) { + paneOpeners[paneId] = open +} + +// TOOL PANELS (terminal, logs, …): their toggle COLLAPSES the zone to a rail +// (tab stays) instead of hiding it, and the tab's ✕ REMOVES it (vs a session +// tile, whose ✕ closes the session). Membership tells the renderer which +// semantics a tab gets. See bindPaneCollapse in the controller. +const collapsePanes = new Set<string>() + +export function markCollapsePane(paneId: string) { + collapsePanes.add(paneId) +} + +export function isCollapsePane(paneId: string): boolean { + return collapsePanes.has(paneId) +} + +const resetHandlers = new Set<() => void>() + +/** Run during a layout reset, BEFORE generic adoption — lets an owner + * pre-place its panes into the fresh default tree (session tiles collapse + * into main as tabs) so adoption sees them already placed and never scatters + * them to their old edges. */ +export function registerLayoutResetHandler(fn: () => void): () => void { + resetHandlers.add(fn) + + return () => { + resetHandlers.delete(fn) + } +} + +/** The zone the user last interacted with (clicked / focused into) — the ⌘W + * target when nothing is DOM-focused (activeElement is often `body` after a + * click lands on a non-focusable surface). Tracked by trackActiveTreeGroup. */ +export const $activeTreeGroup = atom<null | string>(null) + +/** Record the interacted zone (pointerdown / focusin). Idempotent. */ +export function noteActiveTreeGroup(groupId: null | string) { + if (groupId !== $activeTreeGroup.get()) { + $activeTreeGroup.set(groupId) + } +} + +/** Install the active-zone tracker (call once from the tree root). Records the + * `[data-tree-group]` under each pointerdown / focusin so ⌘W knows which + * zone's tab to close even when nothing is DOM-focused. */ +export function trackActiveTreeGroup(): () => void { + const track = (event: Event) => { + const el = event.target instanceof HTMLElement ? event.target : null + const groupId = el?.closest<HTMLElement>('[data-tree-group]')?.dataset.treeGroup + + if (groupId) { + noteActiveTreeGroup(groupId) + } + } + + window.addEventListener('pointerdown', track, true) + window.addEventListener('focusin', track, true) + + return () => { + window.removeEventListener('pointerdown', track, true) + window.removeEventListener('focusin', track, true) + } +} + +const isUncloseablePane = (paneId: string): boolean => + Boolean( + (registry.getArea('panes').find(c => c.id === paneId)?.data as { uncloseable?: boolean } | undefined)?.uncloseable + ) + +/** ⌘W "main tabs always": close the MAIN (workspace) zone's active tab, unless + * it's the uncloseable workspace itself. Returns false when there's nothing to + * close, so ⌘W stays a no-op — it never closes the window. */ +export function closeWorkspaceTab(): boolean { + const tree = $layoutTree.get() + const active = tree ? findGroupOfPane(tree, 'workspace')?.active : null + + if (!active || isUncloseablePane(active)) { + return false + } + + closeTreePane(active) + + return true +} + +/** Closeable siblings of `paneId` within its group, split by position — powers + * the tab menu's Close-others / Close-to-the-right verbs (and their enablement). */ +function closeableTreeSiblings(paneId: string): { others: string[]; right: string[] } { + const tree = $layoutTree.get() + const panes = (tree ? findGroupOfPane(tree, paneId) : null)?.panes ?? [] + const idx = panes.indexOf(paneId) + + return { + others: panes.filter(id => id !== paneId && !isUncloseablePane(id)), + right: panes.filter((id, i) => i > idx && !isUncloseablePane(id)) + } +} + +/** Closeable-tab counts for a tab's menu enablement (`all` includes self). */ +export function treeTabCloseTargets(paneId: string): { all: number; others: number; right: number } { + const { others, right } = closeableTreeSiblings(paneId) + + return { all: others.length + (isUncloseablePane(paneId) ? 0 : 1), others: others.length, right: right.length } +} + +export function closeOtherTreeTabs(paneId: string): void { + closeableTreeSiblings(paneId).others.forEach(closeTreePane) +} + +export function closeTreeTabsToRight(paneId: string): void { + closeableTreeSiblings(paneId).right.forEach(closeTreePane) +} + +/** Close every closeable tab in `paneId`'s group (the uncloseable workspace stays). */ +export function closeAllTreeTabs(paneId: string): void { + const tree = $layoutTree.get() + const panes = (tree ? findGroupOfPane(tree, paneId) : null)?.panes ?? [] + + panes.filter(id => !isUncloseablePane(id)).forEach(closeTreePane) +} + +/** Pane ids in the tree under a `${prefix}:` namespace — lets a mirror prune + * panes the SHARED (cross-profile) tree persisted for tiles that no longer + * back the current profile (a profile switch reloads with the other profile's + * tile panes still stacked in). */ +export function treePanesWithPrefix(prefix: string): string[] { + const tree = $layoutTree.get() + + return tree ? allPaneIds(tree).filter(id => id.startsWith(prefix)) : [] +} + +/** ⌘1…⌘9: activate the Nth tab of the FOCUSED zone (the interaction tracker's + * group), but only when it's a real tab strip (≥2 panes). Returns false so the + * caller falls back to its default (profile switch) — the number keys mean + * "switch tab" only while a multi-tab zone holds focus. */ +export function activateTreeTabSlot(slot: number): boolean { + const groupId = $activeTreeGroup.get() + const tree = $layoutTree.get() + const panes = (groupId && tree ? findGroup(tree, groupId)?.panes : null) ?? [] + + if (panes.length < 2 || slot < 1 || slot > panes.length) { + return false + } + + activateTreePane(groupId!, panes[slot - 1]) + + return true +} + +/** ⌃Tab / ⌃⇧Tab: cycle the FOCUSED zone's tabs (wrapping) — but only a + * session/main strip with ≥2 tabs. Returns false so the caller falls back to + * the recent-session switcher when the focus isn't a chat tab strip. */ +export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean { + const groupId = $activeTreeGroup.get() + const tree = $layoutTree.get() + const group = groupId && tree ? findGroup(tree, groupId) : null + const panes = group?.panes ?? [] + + if (panes.length < 2 || !panes.some(id => id === 'workspace' || id.startsWith('session-tile:'))) { + return false + } + + const idx = Math.max(0, panes.indexOf(group!.active ?? '')) + activateTreePane(group!.id, panes[(idx + direction + panes.length) % panes.length]) + + return true +} + +/** Remove a pane from the tree WITHOUT a dismissal record — for surfaces + * whose lifecycle an owner store drives (session tiles): the owner removes + * the contribution too, and a later re-open must re-adopt cleanly. */ +export function removeTreePane(paneId: string) { + const tree = $layoutTree.get() + + if (tree) { + commit(removePane(tree, paneId)) + } +} + +/** The layout's root ROW — the split that contains main + the side columns. + * Usually the root itself (Default, Focus); in a column-root layout (Terminal + * deck, Quad) it's the row child that holds sessions/workspace/files. Returns + * null when the tree has no row split with side-eligible panes. */ +function rootRow(): SplitNode | null { + const tree = $layoutTree.get() + + if (!tree || tree.type !== 'split') { + return null + } + + if (tree.orientation === 'row') { + return tree + } + + // Column root: find the row child that contains the main pane — that's the + // row the side-collapse system operates on (sessions left, files right). + const panes = registry.getArea('panes') + + const hasMain = (node: LayoutNode): boolean => { + if (node.type === 'group') { + return node.panes.some(id => + (panes.find(p => p.id === id)?.data as { placement?: string } | undefined)?.placement === 'main' + ) + } + + return node.children.some(hasMain) + } + + return tree.children.find(child => child.type === 'split' && child.orientation === 'row' && hasMain(child)) as + | SplitNode + | undefined ?? null +} + +/** Which root-row side a pane currently lives in, or null when it's nested + * with main (dragged into the middle) — where a side collapse can't hide it. + * Lets side-bound closers (files/sessions) fall back to dismissal. */ +export function paneRootSide(paneId: string): null | TreeSide { + const row = rootRow() + + if (!row) { + return null + } + + const panes = registry.getArea('panes') + const child = row.children.find(c => allPaneIds(c).includes(paneId)) + + return child ? rootChildSide(child, id => panes.find(p => p.id === id)) : null +} + +/** The closer-less Close: dismiss the pane (removed + remembered; reveal + * intent or a layout reset un-dismisses). */ +export function dismissTreePane(paneId: string) { + const tree = $layoutTree.get() + + if (tree) { + setDismissed(paneId, true) + commit(removePane(tree, paneId)) + } +} + +export function closeTreePane(paneId: string) { + const closer = paneClosers[paneId] + + if (closer) { + closer() + + return + } + + // A plugin's pane: Close = DISABLE the plugin — the same switch as + // Settings → Plugins, so recovery is discoverable and symmetric. The + // contribution unregisters but the pane id STAYS in the tree, so + // re-enabling restores it exactly where it was. (Dismissal + removal + // would strand the pane with no way back short of a layout reset.) + const source = registry.getArea('panes').find(c => c.id === paneId)?.source + + if (source?.startsWith('plugin:')) { + const pluginId = source.slice('plugin:'.length) + void setPluginEnabled(pluginId, false) + notify({ + kind: 'info', + title: translateNow('zones.pluginDisabled', pluginId), + message: translateNow('zones.pluginDisabledBody') + }) + + return + } + + dismissTreePane(paneId) +} + +/** + * POSITIONAL side collapse — the titlebar's left/right sidebar toggles (and + * ⌘B / ⌘J). Everything on that side of the MAIN zone in the root row hides + * together, whatever panes live there (this is what makes the buttons agree + * with a rearranged layout; the flip derivation works the same way). An AND + * on top of per-pane visibility: zone shown ⇔ side open ∧ some pane shown. + */ +export type TreeSide = 'left' | 'right' + +export const $collapsedTreeSides = atom<ReadonlySet<TreeSide>>(new Set()) + +// Side visibility is DERIVED from an app store (the binding owns persistence +// + button state); reveals flow back through its setter so they never +// disagree with the flag. +const sideOpeners: Partial<Record<TreeSide, (open: boolean) => void>> = {} + +export function setTreeSideCollapsed(side: TreeSide, collapsed: boolean) { + const next = toggledSet($collapsedTreeSides.get(), side, collapsed) + + if (next) { + $collapsedTreeSides.set(next) + } + + // Opening a side is an intent to SEE it — heal any pane of that side that a + // stale dismissal record removed from the tree, so ⌘B/⌘J can never press on + // nothing. Closing chrome panes is NEVER permanent (main parity). + if (!collapsed) { + restoreDismissedSidePanes(side) + } +} + +/** + * Does the layout have a collapsible root side of `side`? ⌘J's normal target is + * the right sidebar; a layout without one (e.g. a terminal-on-bottom preset) + * lets callers fall back to the terminal so ⌘J is never a dead key. Semantic — + * reuses `rootChildSide`, so it tracks a ⌘\ flip / drag like the toggles do. + */ +export function layoutHasRootSide(side: TreeSide): boolean { + const row = rootRow() + + if (!row) { + return false + } + + const panes = registry.getArea('panes') + + return row.children.some(child => rootChildSide(child, id => panes.find(p => p.id === id)) === side) +} + +/** + * Un-dismiss + re-adopt every registered pane whose placement maps to `side` + * (the same semantic mapping as `rootChildSide`: 'left' panes ⇔ ⌘B, everything + * else non-main ⇔ ⌘J). Dismissal records for core chrome panes only exist as + * legacy state (they all register closers now), but they must not strand the + * pane where only a layout reset can recover it. + */ +function restoreDismissedSidePanes(side: TreeSide) { + const dismissed = $dismissedPanes.get() + + if (dismissed.size === 0) { + return + } + + let changed = false + + for (const pane of registry.getArea('panes')) { + if (!dismissed.has(pane.id)) { + continue + } + + const placement = (pane.data as { placement?: string } | undefined)?.placement + const paneSide = placement === 'left' ? 'left' : placement === 'main' ? null : 'right' + + if (paneSide === side) { + setDismissed(pane.id, false) + changed = true + } + } + + if (changed) { + adoptContributedPanes() + } +} + +/** Bind a side's visibility to an app store (mirror of bindPaneVisibility). */ +export function bindTreeSideVisibility( + side: TreeSide, + $open: { get(): boolean; listen(fn: (open: boolean) => void): void }, + setOpen: (open: boolean) => void +) { + sideOpeners[side] = setOpen + setTreeSideCollapsed(side, !$open.get()) + $open.listen(open => setTreeSideCollapsed(side, !open)) +} + +/** The chrome toggle owning `paneId`'s root-row column — SEMANTIC, matching + * the renderer's `rootChildSide`: ⌘B ⇔ the sessions column (left-placement + * panes) wherever it sits, ⌘J ⇔ the other side columns. Null for the main + * column (never side-collapsed). */ +export function treeSideOfPane(paneId: string): TreeSide | null { + const row = rootRow() + + if (!row) { + return null + } + + const child = row.children.find(node => allPaneIds(node).includes(paneId)) + + if (!child) { + return null + } + + const placementOf = (id: string) => + (registry.getArea('panes').find(c => c.id === id)?.data as { placement?: string } | undefined)?.placement + + const placements = allPaneIds(child).map(placementOf) + + if (placements.includes('main')) { + return null + } + + return placements.includes('left') ? 'left' : 'right' +} + +/** + * App intent "show pane X" (a preview target landed, ⌘G opened review, …): + * open its side, unhide it, and bring it to the front of its group. + */ +export function revealTreePane(paneId: string) { + // Reveal beats a Close: un-dismiss and let adoption put the pane back. + if ($dismissedPanes.get().has(paneId)) { + setDismissed(paneId, false) + adoptContributedPanes() + } + + const side = treeSideOfPane(paneId) + + if (side && $collapsedTreeSides.get().has(side)) { + const open = sideOpeners[side] + + // Through the bound store when there is one, so the toggle stays truthful. + if (open) { + open(true) + } else { + setTreeSideCollapsed(side, false) + } + } + + const hiddenNow = $hiddenTreePanes.get() + + if (hiddenNow.has(paneId)) { + setTreePaneHidden(paneId, false) + + return + } + + const tree = $layoutTree.get() + const group = tree ? findGroupOfPane(tree, paneId) : null + + if (tree && group) { + // A minimized zone must be restored — "reveal" means show the pane, not + // just front its tab behind a collapsed rail. Without this, a tool panel + // (terminal/logs) in a shared zone stays minimized after its toggle opens + // it: setPaneCollapsed's shared-zone branch calls revealTreePane instead + // of toggleTreeGroupMinimized, so the zone never un-minimizes and the + // pane appears to "close but not open" on ctrl-` / tab click. + let next = tree + + if (group.minimized) { + next = setGroupMinimized(next, group.id, false) + } + + if (group.active !== paneId) { + next = setActivePaneOp(next, group.id, paneId) + } + + if (next !== tree) { + commit(next) + } + } +} + +/** + * Narrow viewport (the app's sidebar-collapse breakpoint): panes whose + * contribution declares `collapsible: true` leave the grid and become + * edge overlays (see NarrowOverlays in renderer.tsx). + */ +// Optional-chained + `typeof window` guarded like every other matchMedia call +// site: this module is imported by non-DOM code paths (session actions) whose +// test env has no `window`/`matchMedia` — an unguarded call throws at load. +const narrowQuery = typeof window !== 'undefined' ? window.matchMedia?.(SIDEBAR_COLLAPSE_MEDIA_QUERY) : undefined + +export const $narrowViewport = atom(Boolean(narrowQuery?.matches)) + +narrowQuery?.addEventListener('change', event => $narrowViewport.set(event.matches)) + +/** The titlebar flip toggle (⌘\): mirror the whole layout left↔right. */ +export function mirrorLayoutTree() { + const tree = $layoutTree.get() + + if (tree) { + commit(mirrorTreeHorizontal(tree)) + } +} + +export interface DropHint { + kind: 'group' + /** The zone a drop will land in (ClosestCenter among `groupIds`). */ + groupId?: string + /** Full highlighted set (multi-zone when Shift extends the range). */ + groupIds?: string[] + pos?: DropPosition + /** Hovering the target's TAB STRIP: the drop stacks at a specific slot — + * before this pane id, or at the end (`before: null`). The strip renders + * the insertion divider; the zone sheet stands down. */ + stack?: { before: null | string } +} + +/** Live drop target under the pointer while dragging. */ +export const $dropHint = atom<DropHint | null>(null) + +/** + * Derived session-drag booleans for HEAVY subscribers (the chat surfaces). + * `$dropHint` churns on every pointer-crossing during ANY drag; a chat surface + * subscribing to it raw re-renders its whole thread per hint change. These + * computeds collapse the churn to booleans that only notify on actual flips — + * and stay `false` throughout pane/tab drags, which chat never cares about. + */ +export const $sessionTileDragging = computed($treeDragging, dragging => dragging === SESSION_TILE_DRAG) + +/** True while a session drag aims at a zone EDGE (a tile split) or a tab + * strip (a stack) — the moments the chat surfaces' "link to chat" overlay + * must stand down. */ +export const $sessionTileEdgeHover = computed( + [$treeDragging, $dropHint], + (dragging, hint) => + dragging === SESSION_TILE_DRAG && ((hint?.pos !== undefined && hint.pos !== 'center') || hint?.stack !== undefined) +) + +/** + * Adopt panes present in `source` but missing from `target`: each joins the + * group its source siblings map to in the target (first group as a last + * resort). Layout changes never lose panes. + */ +function adoptMissingPanes(target: LayoutNode, source: LayoutNode): LayoutNode { + const have = new Set(allPaneIds(target)) + let next = target + + for (const paneId of allPaneIds(source)) { + if (have.has(paneId)) { + continue + } + + const sibling = findGroupOfPane(source, paneId)?.panes.find(p => have.has(p)) + const targetId = (sibling ? findGroupOfPane(next, sibling)?.id : undefined) ?? groupLeafIds(next)[0] + + if (targetId) { + // Silent adoption: don't steal the target zone's active tab (logs). + next = insertAtGroup(next, targetId, paneId, 'center', null, false) ?? next + have.add(paneId) + } + } + + return next +} + +/** + * Declare the app's default tree. Adopted immediately when the user has no + * persisted customization; a persisted tree from an older default adopts any + * panes it's missing. + */ +export function declareDefaultTree(tree: LayoutNode) { + defaultTree = tree + const current = $layoutTree.get() + + if (!current) { + $layoutTree.set(tree) + + return + } + + const next = adoptMissingPanes(current, tree) + + if (next !== current) { + commit(next) + } +} + +/** + * LIVE pane adoption — a `panes` contribution that isn't in the tree yet + * (a plugin registered after boot, incl. runtime-loaded ones) joins the + * tree via the SAME primitive a human drag/drop commits with + * (`insertAtGroup`: anchor group + side). The pane's data supplies the + * gesture: + * + * - `dock: { pane, pos }` — "drop me on that edge of that pane". Any pane, + * any side, exactly what the drop chips do. + * - otherwise the semantic `placement` role infers the anchor: stack with + * a settled pane of the same placement, main zone as last resort. + * + * Happens once per pane lifetime (the committed tree remembers it across + * boots), so user rearrangement wins from then on and plugin reloads keep + * the pane where the user left it. + */ +interface PaneDockHint { + pane: string + pos: DropPosition + /** Center docks: stack BEFORE this pane id (the strip divider's slot). */ + before?: null | string +} + +function adoptContributedPanes(): void { + const tree = $layoutTree.get() + + if (!tree) { + return + } + + const panes = registry.getArea('panes') + + const dataOf = (paneId: string) => + panes.find(c => c.id === paneId)?.data as { placement?: string; dock?: PaneDockHint } | undefined + + const placementOf = (paneId: string) => dataOf(paneId)?.placement + const mainId = panes.find(c => placementOf(c.id) === 'main')?.id + const inTree = new Set(allPaneIds(tree)) + + // Plugin panes are never dismissed anymore (Close disables the plugin + // instead) — drop stale entries so panes stranded by the old behavior + // re-adopt on their own. + for (const pane of panes) { + if (pane.source?.startsWith('plugin:') && $dismissedPanes.get().has(pane.id)) { + setDismissed(pane.id, false) + } + } + + const dismissed = $dismissedPanes.get() + const missing = panes.filter(c => !inTree.has(c.id) && !dismissed.has(c.id)) + + if (missing.length === 0) { + return + } + + let next = tree + + for (const pane of missing) { + const dock = dataOf(pane.id)?.dock + const placement = placementOf(pane.id) ?? 'right' + + const anchor = + (dock && allPaneIds(next).includes(dock.pane) ? dock.pane : undefined) ?? + allPaneIds(next).find(id => id !== pane.id && placementOf(id) === placement) ?? + mainId + + const target = findGroupOfPane(next, anchor ?? '')?.id + + if (target) { + // Silent adoption: don't front over the zone's active tab — a reveal does. + next = insertAtGroup(next, target, pane.id, dock?.pos ?? 'center', dock?.before, false) ?? next + + // An adopted pane ARRIVES with its chip showing — a surprise zone with + // zero chrome has no obvious handle to drag or close. (Explicit reveal; + // the next structural op returns lone panes to the auto-hide default.) + const landed = findGroupOfPane(next, pane.id) + + if (landed) { + next = setGroupHeaderHiddenOp(next, landed.id, false) + } + } + } + + if (next !== tree) { + commit(next) + } +} + +/** Adopt now + on every registry change (call once from the app root). */ +export function watchContributedPanes(): void { + adoptContributedPanes() + registry.subscribe(adoptContributedPanes) +} + +function commit(next: LayoutNode | null) { + if (!next) { + return + } + + $layoutTree.set(next) + persist(next) +} + +// --------------------------------------------------------------------------- +// USER-PLACED panes — "their spot wins". A pane the user has explicitly +// dragged (zone move / span / zone-menu split) keeps that placement; auto- +// docking (dockPaneBeside) only steers panes the user hasn't touched. +// Presets and resets hand placement back to the app. +// --------------------------------------------------------------------------- + +const USER_PLACED_KEY = 'hermes.desktop.userPlacedPanes.v1' + +export const $userPlacedPanes = atom<ReadonlySet<string>>(new Set(readJson<string[]>(USER_PLACED_KEY) ?? [])) + +function saveUserPlaced(next: ReadonlySet<string>) { + $userPlacedPanes.set(next) + writeJson(USER_PLACED_KEY, next.size === 0 ? null : [...next]) +} + +function markPaneUserPlaced(paneId: string) { + const next = toggledSet($userPlacedPanes.get(), paneId, true) + + if (next) { + saveUserPlaced(next) + } +} + +/** + * Dock `paneId` directly beside `anchorPaneId` — the "preview opens NEXT TO + * the file tree" contract, position-aware: wherever the anchor lives (default + * rail, flipped via ⌘\, dragged into a stack, tabbed into main), the pane + * lands adjacent to it. Side rule: an anchor sitting right of the main zone + * gets the pane on its LEFT (the rail slides open toward the chat — main + * parity); an anchor left of main, stacked with it, or anywhere else gets it + * on the RIGHT. Skipped when the USER has placed the pane themselves, or the + * anchor isn't visible. Idempotent — a pane already beside its anchor is a + * shape no-op. + */ +export function dockPaneBeside(paneId: string, anchorPaneId: string) { + const tree = $layoutTree.get() + + if (!tree || $userPlacedPanes.get().has(paneId)) { + return + } + + const panes = registry.getArea('panes') + const anchor = findGroupOfPane(tree, anchorPaneId) + + // Anchor must be a live, shown pane — never dock beside a hidden file tree. + if (!anchor || $hiddenTreePanes.get().has(anchorPaneId) || !panes.some(c => c.id === anchorPaneId)) { + return + } + + // The uncloseable main workspace (session tiles are placement:'main' too, + // but closeable, so the uncloseable flag disambiguates). + const mainId = panes.find(c => { + const data = c.data as { placement?: string; uncloseable?: boolean } | undefined + + return data?.placement === 'main' && data.uncloseable + })?.id + + const order = allPaneIds(tree) + + const anchorRightOfMain = + !!mainId && !anchor.panes.includes(mainId) && order.indexOf(anchorPaneId) > order.indexOf(mainId) + + const pos: DropPosition = anchorRightOfMain ? 'left' : 'right' + + // A dismissed pane re-enters HERE (beside the anchor), not via adoption's + // placement fallback — clear the record so the two never disagree. + if ($dismissedPanes.get().has(paneId)) { + setDismissed(paneId, false) + } + + const next = findGroupOfPane(tree, paneId) + ? movePaneOp(tree, paneId, { groupId: anchor.id, pos }) + : insertAtGroup(tree, anchor.id, paneId, pos) + + if (next && next !== tree) { + commit(next) + } +} + +export function moveTreePane(paneId: string, target: { groupId: string; pos: DropPosition; before?: null | string }) { + const tree = $layoutTree.get() + + if (!tree) { + return + } + + const next = movePaneOp(tree, paneId, target) + + // movePane returns the SAME root for no-op drops ("stays here") — only a + // real move customizes the preset or pins the pane as user-placed. + if (next !== tree) { + commit(next) + markActivePreset('custom') + markPaneUserPlaced(paneId) + } +} + +/** + * Replace the whole tree (preset application). Panes living in the CURRENT + * tree that the preset doesn't know about (e.g. plugin panes vs a bundled + * preset) are adopted into the group their current siblings land in, so + * applying a preset never loses a pane. + */ +export function applyTree(tree: LayoutNode, presetId: string) { + const previous = $layoutTree.get() + + // A preset defines the layout's SIZES too — stale drag overrides from the + // previous arrangement would distort it. Same for user-placed pins: picking + // a layout hands pane placement back to the app (auto-docking resumes). + clearAllPaneSizeOverrides() + saveUserPlaced(new Set()) + commit(previous ? adoptMissingPanes(tree, previous) : tree) + markActivePreset(presetId) + + // Picking a named layout is an intent to SEE its panes. Toggle-gated panes + // (the terminal, whose visibility a store owns) would otherwise stay + // collapsed after the tree changes — so reveal the ones that opt in through + // their owning store, keeping the ⌃`/toggle state truthful. Iterate the + // preset's DECLARED panes (not the adopted result): logs is auto-adopted + // hidden into every tree, so only a preset that explicitly places it (Quad) + // should turn it on. + const panes = registry.getArea('panes') + + for (const paneId of allPaneIds(tree)) { + const data = panes.find(c => c.id === paneId)?.data as { revealOnPreset?: boolean } | undefined + + if (data?.revealOnPreset) { + paneOpeners[paneId]?.() + } + } +} + +/** + * Shift-drag span: merge the highlighted zones into one holding `paneId`. Falls + * back to a single-zone move at `fallbackGroupId` when the set can't merge + * (non-rectangular selection). + */ +export function mergeTreeZones(groupIds: string[], paneId: string, fallbackGroupId: string | null) { + const tree = $layoutTree.get() + + if (!tree) { + return + } + + const merged = mergeZonesWithPaneOp(tree, groupIds, paneId) + + if (merged) { + commit(merged) + markActivePreset('custom') + markPaneUserPlaced(paneId) + } else if (fallbackGroupId) { + moveTreePane(paneId, { groupId: fallbackGroupId, pos: 'center' }) + } +} + +export function activateTreePane(groupId: string, paneId: string) { + const tree = $layoutTree.get() + + if (tree) { + commit(setActivePaneOp(tree, groupId, paneId)) + } +} + +export function reorderTreePane(groupId: string, paneId: string, toIndex: number) { + const tree = $layoutTree.get() + + if (tree) { + commit(reorderPaneInGroupOp(tree, groupId, paneId, toIndex)) + markActivePreset('custom') + } +} + +/** Split a zone on `side`, moving `movePaneId` out of its stack into the new + * zone (VS Code split-and-move — the zone menu's Split actions). */ +export function splitTreeZone(groupId: string, side: RootEdge, movePaneId: string) { + const tree = $layoutTree.get() + + if (tree) { + commit(splitGroupZoneOp(tree, groupId, side, movePaneId)) + markActivePreset('custom') + markPaneUserPlaced(movePaneId) + } +} + +export function toggleTreeGroupMinimized(groupId: string, minimized: boolean) { + const tree = $layoutTree.get() + + if (tree) { + commit(setGroupMinimized(tree, groupId, minimized)) + } +} + +/** The group hosting `paneId`, or null. */ +function paneGroup(paneId: string) { + const tree = $layoutTree.get() + + return tree ? findGroupOfPane(tree, paneId) : null +} + +/** Collapse/restore a pane's ZONE to a minimized rail — its tab stays visible. + * Store-driven (one-way): a tool panel's $open store mirrors here via + * bindPaneCollapse, so a toggle collapses rather than hides. */ +export function setPaneCollapsed(paneId: string, collapsed: boolean) { + const group = paneGroup(paneId) + + if (!group) { + return + } + + // SHARED zone (terminal + logs, or a tool panel stacked with the workspace): + // one minimized flag but per-pane toggle stores — so "collapsed" is the + // ZONE's. Open → reveal + front; close acts ONLY for the on-screen tab. An + // inactive toggle folding its visible sibling is what re-collapsed the zone + // on every boot (broke collapse persistence). + if (group.panes.length > 1) { + if (collapsed && group.active === paneId) { + if (group.panes.some(isUncloseablePane)) { + // Workspace can't minimize (strands the app) → tab-switch to a sibling + // (guaranteed to exist by length > 1). + const at = group.panes.indexOf(paneId) + + activateTreePane(group.id, group.panes[at - 1] ?? group.panes[at + 1]) + } else { + toggleTreeGroupMinimized(group.id, true) // pure tool zone folds as a unit + } + } else if (!collapsed) { + revealTreePane(paneId) + } + + return + } + + if (Boolean(group.minimized) !== collapsed) { + toggleTreeGroupMinimized(group.id, collapsed) + + if (!collapsed) { + revealTreePane(paneId) + } + } +} + +/** Restore a minimized tool pane the truthful way — through its store opener + * when bound (keeps ⌃`/titlebar toggles in sync), else just un-minimize + + * front. Used by the rail (tab / whole-rail click) and the header chevron. */ +export function restoreTreePane(paneId: string) { + const open = paneOpeners[paneId] + + if (open) { + open() + + // The opener may be a no-op — the store was already true (zone minimized + // via the zone menu, not the toggle). nanostores don't fire listeners on + // a same-value .set(), so the bindPaneCollapse listener never runs and + // the zone stays minimized. Un-minimize directly when that happens. + const group = paneGroup(paneId) + + if (group?.minimized) { + toggleTreeGroupMinimized(group.id, false) + } + + revealTreePane(paneId) + + return + } + + const group = paneGroup(paneId) + + if (group) { + toggleTreeGroupMinimized(group.id, false) + activateTreePane(group.id, paneId) + } +} + +/** Collapse a tool pane through its store closer (truthful), else minimize the + * zone directly. Gated on isCollapsePane so a non-tool pane's closer (a tile's + * REMOVES it) is never mistaken for a collapse. */ +export function collapseTreePane(paneId: string) { + const close = paneClosers[paneId] + + if (isCollapsePane(paneId) && close) { + close() + + return + } + + const group = paneGroup(paneId) + + if (group) { + toggleTreeGroupMinimized(group.id, true) + } +} + +/** Hide/show a zone's header entirely (double-click gesture). */ +export function setTreeGroupHeaderHidden(groupId: string, headerHidden: boolean) { + const tree = $layoutTree.get() + + if (tree) { + commit(setGroupHeaderHiddenOp(tree, groupId, headerHidden)) + } +} + +export function setTreeSplitWeights(splitId: string, weights: number[]) { + const tree = $layoutTree.get() + + if (tree) { + // Weight drags are high-frequency: update live, persist on the trailing edge. + $layoutTree.set(setSplitWeightsOp(tree, splitId, weights)) + } +} + +function findSplitWeights(node: LayoutNode, splitId: string): number[] | null { + if (node.type !== 'split') { + return null + } + + if (node.id === splitId) { + return node.weights + } + + for (const child of node.children) { + const hit = findSplitWeights(child, splitId) + + if (hit) { + return hit + } + } + + return null +} + +/** + * The weights a layout preset declares for `splitId` — the ACTIVE preset + * first, then any other preset that knows the id. (Rearranging panes marks + * the active preset 'custom' but zone STRUCTURE — and so split ids — comes + * from whichever preset was applied, so the original baseline stays + * findable.) Null when no preset has a matching-shape split. + */ +export function presetSplitWeights(splitId: string, length: number): number[] | null { + const activeId = $activePresetId.get() + const presets = [...registry.getArea('layouts')].sort((a, b) => Number(b.id === activeId) - Number(a.id === activeId)) + + for (const preset of presets) { + const weights = preset.data && isLayoutNode(preset.data) ? findSplitWeights(preset.data, splitId) : null + + if (weights && weights.length === length) { + return [...weights] + } + } + + return null +} + +export function persistTree() { + persist($layoutTree.get()) +} + +export function resetLayoutTree() { + persist(null) + clearAllPaneSizeOverrides() + // Reset restores EVERYTHING — closed panes included — and hands pane + // placement back to the app (user-placed pins cleared). + saveDismissed(new Set()) + saveUserPlaced(new Set()) + $layoutTree.set(defaultTree) + markActivePreset('default') + // Owners PRE-PLACE their panes into the fresh default (session tiles stack + // into main as tabs) FIRST, so generic adoption sees them already in-tree + // and never scatters them to their old edges. + resetHandlers.forEach(fn => fn()) + // Everything still missing (plugin panes) adopts by placement. + adoptContributedPanes() + + // "Restore everything" includes collapsed SIDES: reopen every bound side + // (through its store, so $sidebarOpen / the toggles stay truthful). Without + // this a sidebar hidden before the reset silently survives it, flipping the + // next ⌘B into a SHOW — so hiding never appears to persist. + for (const side of Object.keys(sideOpeners) as TreeSide[]) { + sideOpeners[side]?.(true) + } +} + +// Dev hook for automation. +if (import.meta.env.DEV && typeof window !== 'undefined') { + ;(window as unknown as Record<string, unknown>).__HERMES_LAYOUT_TREE__ = { + close: closeTreePane, + dismissed: () => $dismissedPanes.get(), + get: () => $layoutTree.get(), + move: moveTreePane, + registry, + reset: resetLayoutTree, + reveal: revealTreePane + } +} diff --git a/apps/desktop/src/components/pane-shell/tree/zone-editor.tsx b/apps/desktop/src/components/pane-shell/tree/zone-editor.tsx new file mode 100644 index 000000000000..36d4f3cbb789 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/zone-editor.tsx @@ -0,0 +1,507 @@ +/** + * Zone editor — the FancyZones grid editor experience, ported from PowerToys' + * `GridEditor.xaml.cs` interactions onto the verbatim model port (grid-model.ts): + * + * - A full-screen canvas of numbered translucent zones. + * - A splitter line follows the cursor inside the hovered zone; CLICK splits + * at that position. Hold SHIFT to flip the line horizontal (row split). + * - DRAG across zones to rubber-band select; the selection expands to its + * rectangular closure (ComputeClosure) and a Merge button appears. + * - Shared zone edges are draggable resizer thumbs (multi-zone edges move + * together, min-size clamped) — GridData.Drag semantics. + * - Templates: Columns / Rows / Grid / Priority with a zone-count stepper. + * + * Saving converts the grid to our runtime layout tree (guillotine cuts) and + * registers it as a user preset; non-guillotine arrangements (pinwheels) + * disable Save with an explanation. + */ + +import { useStore } from '@nanostores/react' +import { atom } from 'nanostores' +import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { registry } from '@/contrib/registry' +import { useI18n } from '@/i18n' +import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' +import { cn } from '@/lib/utils' + +import { + canSplit, + doMerge, + dragResizer, + type GridLayout, + type GridResizer, + type GridZone, + initColumns, + initGrid, + initPriorityGrid, + initRows, + mergeClosureIndices, + modelToResizers, + modelToZones, + MULTIPLIER, + splitZone +} from './grid-model' +import { gridIsTreeExpressible, gridToTree, type PanePlacementHint } from './grid-to-tree' +import { allPaneIds } from './model' +import { applyLayoutPreset, saveLayoutPresetTree } from './presets' +import { $layoutTree } from './store' + +export const $zoneEditorOpen = atom(false) + +const SPLIT_SNAP = 50 // model units (0.5%) + +const pct = (v: number) => `${(v / MULTIPLIER) * 100}%` + +interface SplitPreview { + zoneIndex: number + orientation: 'horizontal' | 'vertical' + position: number +} + +interface SelectBox { + x0: number + y0: number + x1: number + y1: number +} + +/** Resizer screen geometry derived from its zones (model units). */ +function resizerGeometry(resizer: GridResizer, zones: GridZone[]) { + const all = [...resizer.negativeSideIndices, ...resizer.positiveSideIndices].map(i => zones[i]) + + if (resizer.orientation === 'horizontal') { + return { + at: zones[resizer.positiveSideIndices[0]].top, + from: Math.min(...all.map(z => z.left)), + to: Math.max(...all.map(z => z.right)) + } + } + + return { + at: zones[resizer.positiveSideIndices[0]].left, + from: Math.min(...all.map(z => z.top)), + to: Math.max(...all.map(z => z.bottom)) + } +} + +export function ZoneEditor() { + const { t } = useI18n() + const open = useStore($zoneEditorOpen) + const [model, setModel] = useState<GridLayout>(() => initPriorityGrid(3)) + const [templateCount, setTemplateCount] = useState(3) + const [shift, setShift] = useState(false) + const [splitPreview, setSplitPreview] = useState<SplitPreview | null>(null) + const [selectBox, setSelectBox] = useState<SelectBox | null>(null) + const [selection, setSelection] = useState<number[]>([]) + const [mergeAt, setMergeAt] = useState<{ x: number; y: number } | null>(null) + const [name, setName] = useState('') + const canvasRef = useRef<HTMLDivElement>(null) + + const zones = modelToZones(model) ?? [] + const resizers = modelToResizers(model) + const treeExpressible = gridIsTreeExpressible(model) + + // Shift flips the splitter orientation (FancyZones behavior); Esc closes. + useEffect(() => { + if (!open) { + return + } + + const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.zoneEditor) + + const down = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setShift(true) + } + + // Skip when a nested field (the name Input) already handled Escape, or a + // higher layer owns it. + if (e.key === 'Escape' && !e.defaultPrevented && isTopEscapeLayer(ESCAPE_PRIORITY.zoneEditor)) { + e.preventDefault() + $zoneEditorOpen.set(false) + } + } + + const up = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setShift(false) + } + } + + window.addEventListener('keydown', down) + window.addEventListener('keyup', up) + + return () => { + window.removeEventListener('keydown', down) + window.removeEventListener('keyup', up) + releaseLayer() + } + }, [open]) + + const toModelPoint = useCallback((clientX: number, clientY: number) => { + const rect = canvasRef.current!.getBoundingClientRect() + + return { + x: Math.round(((clientX - rect.x) / rect.width) * MULTIPLIER), + y: Math.round(((clientY - rect.y) / rect.height) * MULTIPLIER) + } + }, []) + + if (!open) { + return null + } + + const zoneAt = (x: number, y: number) => + zones.find(z => x >= z.left && x < z.right && y >= z.top && y < z.bottom) ?? null + + const updateSplitPreview = (clientX: number, clientY: number) => { + const p = toModelPoint(clientX, clientY) + const zone = zoneAt(p.x, p.y) + + if (!zone) { + setSplitPreview(null) + + return + } + + const orientation = shift ? 'horizontal' : 'vertical' + const raw = orientation === 'horizontal' ? p.y : p.x + const position = Math.round(raw / SPLIT_SNAP) * SPLIT_SNAP + + setSplitPreview( + canSplit(model, zone.index, position, orientation) ? { zoneIndex: zone.index, orientation, position } : null + ) + } + + const onCanvasPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => { + if (e.button !== 0 || (e.target as HTMLElement).dataset.resizer !== undefined) { + return + } + + e.preventDefault() + setMergeAt(null) + setSelection([]) + + const start = toModelPoint(e.clientX, e.clientY) + let dragged = false + + const onMove = (ev: PointerEvent) => { + const cur = toModelPoint(ev.clientX, ev.clientY) + + if (!dragged && Math.hypot(cur.x - start.x, cur.y - start.y) < 60) { + return + } + + dragged = true + + const box = { + x0: Math.min(start.x, cur.x), + y0: Math.min(start.y, cur.y), + x1: Math.max(start.x, cur.x), + y1: Math.max(start.y, cur.y) + } + + setSelectBox(box) + + // Zones intersecting the rubber band, expanded to rectangular closure. + const picked = zones + .filter(z => z.left < box.x1 && z.right > box.x0 && z.top < box.y1 && z.bottom > box.y0) + .map(z => z.index) + + setSelection(mergeClosureIndices(model, picked)) + } + + const onUp = (ev: PointerEvent) => { + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + setSelectBox(null) + + if (!dragged) { + // Plain click: split at the previewed line. + if (splitPreview) { + setModel(m => splitZone(m, splitPreview.zoneIndex, splitPreview.position, splitPreview.orientation)) + setSplitPreview(null) + } + + setSelection([]) + + return + } + + const rect = canvasRef.current!.getBoundingClientRect() + setMergeAt({ x: ev.clientX - rect.x, y: ev.clientY - rect.y }) + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + } + + const startResizerDrag = (index: number, e: ReactPointerEvent<HTMLDivElement>) => { + e.preventDefault() + e.stopPropagation() + + const rect = canvasRef.current!.getBoundingClientRect() + const resizer = resizers[index] + const horizontal = resizer.orientation === 'horizontal' + const start = horizontal ? e.clientY : e.clientX + let applied = 0 + + const onMove = (ev: PointerEvent) => { + const px = (horizontal ? ev.clientY : ev.clientX) - start + const total = Math.round((px / (horizontal ? rect.height : rect.width)) * MULTIPLIER) + const step = total - applied + + if (step !== 0) { + setModel(m => { + const next = dragResizer(m, index, step) + + if (next !== m) { + applied = total + } + + return next + }) + } + } + + const onUp = () => { + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + } + + const merge = () => { + setModel(m => doMerge(m, selection)) + setSelection([]) + setMergeAt(null) + } + + const save = () => { + const paneIds = allPaneIds($layoutTree.get() ?? { type: 'group', id: 'tmp', panes: [], active: '' }) + // Placement hints ride on the pane contributions (`data.placement`), so + // zones are assigned by ROLE (main/left/right/bottom), not index order. + const contributions = registry.getArea('panes') + + const placed = paneIds.map(id => ({ + id, + placement: (contributions.find(c => c.id === id)?.data as { placement?: PanePlacementHint } | undefined) + ?.placement + })) + + const tree = gridToTree(model, placed) + + if (!tree) { + return + } + + const id = saveLayoutPresetTree(name || t.zones.customZoneName(zones.length), tree) + + if (id) { + applyLayoutPreset(id, tree) + } + + $zoneEditorOpen.set(false) + } + + const templates = [ + { label: t.zones.templateColumns, make: initColumns }, + { label: t.zones.templateRows, make: initRows }, + { label: t.zones.templateGrid, make: initGrid }, + { label: t.zones.templatePriority, make: initPriorityGrid } + ] + + return ( + <div className="absolute inset-0 z-[70] flex flex-col gap-3 p-6 [-webkit-app-region:no-drag]" style={{ background: 'color-mix(in srgb, var(--ui-bg-chrome) 88%, transparent)', backdropFilter: 'blur(6px)' }}> + {/* Toolbar — Panel-style title + hint, template chooser on the right. */} + <div className="flex items-end justify-between gap-3"> + <div className="min-w-0"> + <h2 className="text-sm font-semibold text-foreground">{t.zones.zoneEditorTitle}</h2> + <p className="text-xs text-muted-foreground/80"> + {t.zones.editorHintPre} + <kbd className="rounded border border-(--ui-stroke-secondary) bg-foreground/5 px-1 font-mono text-[10px]"> + ⇧ + </kbd> + {t.zones.editorHintPost} + </p> + </div> + <div className="flex shrink-0 items-center gap-1.5"> + {templates.map(t => ( + <Button + key={t.label} + onClick={() => { + setSelection([]) + setMergeAt(null) + setModel(t.make(templateCount)) + }} + size="sm" + variant="outline" + > + {t.label} + </Button> + ))} + <Input + className="h-7 w-14 text-center text-xs" + max={8} + min={1} + onChange={e => setTemplateCount(Math.max(1, Math.min(8, Number(e.target.value) || 1)))} + type="number" + value={templateCount} + /> + </div> + </div> + + {/* Canvas */} + <div + className="relative min-h-0 flex-1 cursor-crosshair overflow-hidden rounded-lg border border-(--ui-stroke-secondary)" + onPointerDown={onCanvasPointerDown} + onPointerLeave={() => setSplitPreview(null)} + onPointerMove={e => updateSplitPreview(e.clientX, e.clientY)} + ref={canvasRef} + > + {zones.map(zone => { + const selected = selection.includes(zone.index) + + return ( + <div + className="absolute flex items-center justify-center rounded-[3px] border transition-colors" + key={zone.index} + style={{ + left: pct(zone.left), + top: pct(zone.top), + width: pct(zone.right - zone.left), + height: pct(zone.bottom - zone.top), + // Accent over an elevated base — zones stay legible on dark + // themes where a bare accent wash sinks into the canvas. + background: `color-mix(in srgb, var(--ui-accent) ${selected ? 34 : 12}%, var(--ui-bg-elevated))`, + borderColor: `color-mix(in srgb, var(--ui-accent) ${selected ? 90 : 40}%, transparent)` + }} + > + {/* Quiet zone tag — the app's small-caps label voice, not a + billboard number. */} + <span className="select-none text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-(--ui-text-tertiary)"> + {t.zones.zoneTag(zone.index + 1)} + </span> + </div> + ) + })} + + {/* Splitter preview line following the cursor. */} + {splitPreview && + (() => { + const zone = zones[splitPreview.zoneIndex] + + if (!zone) { + return null + } + + const horizontal = splitPreview.orientation === 'horizontal' + + return ( + <div + className="pointer-events-none absolute" + style={{ + left: horizontal ? pct(zone.left) : pct(splitPreview.position), + top: horizontal ? pct(splitPreview.position) : pct(zone.top), + width: horizontal ? pct(zone.right - zone.left) : 2, + height: horizontal ? 2 : pct(zone.bottom - zone.top), + background: 'var(--ui-accent)' + }} + /> + ) + })()} + + {/* Rubber-band selection box. */} + {selectBox && ( + <div + className="pointer-events-none absolute border" + style={{ + left: pct(selectBox.x0), + top: pct(selectBox.y0), + width: pct(selectBox.x1 - selectBox.x0), + height: pct(selectBox.y1 - selectBox.y0), + background: 'color-mix(in srgb, var(--ui-accent) 10%, transparent)', + borderColor: 'var(--ui-accent)' + }} + /> + )} + + {/* Resizer thumbs on shared edges. */} + {resizers.map((resizer, i) => { + const geo = resizerGeometry(resizer, zones) + const horizontal = resizer.orientation === 'horizontal' + + return ( + <div + className={cn( + 'absolute z-10 flex items-center justify-center', + horizontal ? 'h-[10px] -translate-y-1/2 cursor-row-resize' : 'w-[10px] -translate-x-1/2 cursor-col-resize' + )} + data-resizer={i} + key={`r-${i}`} + onPointerDown={e => startResizerDrag(i, e)} + style={ + horizontal + ? { top: pct(geo.at), left: pct(geo.from), width: pct(geo.to - geo.from) } + : { left: pct(geo.at), top: pct(geo.from), height: pct(geo.to - geo.from) } + } + > + <span + className={cn('pointer-events-none rounded-full', horizontal ? 'h-[4px] w-10' : 'h-10 w-[4px]')} + style={{ background: 'color-mix(in srgb, var(--ui-text-primary) 55%, transparent)' }} + /> + </div> + ) + })} + + {/* Merge affordance at drag-release point. */} + {mergeAt && selection.length > 1 && ( + <div className="absolute z-20 flex gap-1" style={{ left: mergeAt.x, top: mergeAt.y }}> + <Button + className="shadow-lg" + onClick={merge} + onPointerDown={e => e.stopPropagation()} + size="sm" + variant="outline" + > + {t.zones.mergeZones(selection.length)} + </Button> + </div> + )} + </div> + + {/* Footer: save / cancel. */} + <div className="flex items-center gap-1.5"> + <Input + className="h-7 w-64 text-xs" + onChange={e => setName(e.target.value)} + // Escape while typing clears the field and yields — it must not bubble + // up to close the whole editor and lose the name. + onKeyDown={e => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + setName('') + e.currentTarget.blur() + } + }} + placeholder={t.zones.layoutNamePlaceholder(t.zones.customZoneName(zones.length))} + value={name} + /> + <Button disabled={!treeExpressible} onClick={save} size="sm" variant="outline"> + {t.zones.saveApply} + </Button> + <Button onClick={() => $zoneEditorOpen.set(false)} size="sm" variant="ghost"> + {t.common.cancel} + </Button> + {!treeExpressible && <span className="text-xs text-muted-foreground/80">{t.zones.notExpressible}</span>} + <span className="ml-auto text-xs text-muted-foreground/60">{t.zones.zoneCount(zones.length)}</span> + </div> + </div> + ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/zones-engine.ts b/apps/desktop/src/components/pane-shell/tree/zones-engine.ts new file mode 100644 index 000000000000..6be4e6b34f54 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/zones-engine.ts @@ -0,0 +1,367 @@ +/** + * FancyZones runtime engine — verbatim port of the drag/highlight internals + * from PowerToys `FancyZonesLib` (microsoft/PowerToys, MIT): + * + * - `Layout.cpp` -> `zonesFromPoint` (sensitivity-radius capture, the + * "captured but not strictly captured" rejection, the overlap resolution + * algorithms incl. ClosestCenter with OVERLAPPING_CENTERS_SENSITIVITY), + * `getCombinedZoneRange`, `getCombinedZonesRect`. + * - `HighlightedZones.cpp` -> the `HighlightedZones` update state machine + * (initial-zone latch + combined range while "select many" is held). + * - `ZonesOverlay.cpp` -> animation timing: FadeInDurationMillis = 200, + * FlashZonesDurationMillis = 700, alpha = clamp(t/200, 0.001, 1), and the + * fill-alpha rule (highlightOpacity applies to BOTH inactive + highlight). + * - `Colors.cpp` -> ZoneColors resolution (system-theme mode maps accent to + * border+highlight and background to primary; number color auto-contrasts). + */ + +// --------------------------------------------------------------------------- +// Constants (ZonesOverlay.cpp / Layout.cpp / FancyZones defaults) +// --------------------------------------------------------------------------- + +export const FADE_IN_DURATION_MILLIS = 200 +export const FLASH_ZONES_DURATION_MILLIS = 700 +/** LayoutDefaultSettings::DefaultSensitivityRadius. */ +export const DEFAULT_SENSITIVITY_RADIUS = 20 +/** ZoneSelectionAlgorithms::OVERLAPPING_CENTERS_SENSITIVITY. */ +const OVERLAPPING_CENTERS_SENSITIVITY = 75 + +export type OverlappingZonesAlgorithm = 'Smallest' | 'Largest' | 'Positional' | 'ClosestCenter' + +export interface ZoneRect { + left: number + top: number + right: number + bottom: number +} + +export interface EngineZone { + id: string + rect: ZoneRect +} + +interface Point { + x: number + y: number +} + +const zoneArea = (z: EngineZone) => Math.max(0, z.rect.right - z.rect.left) * Math.max(0, z.rect.bottom - z.rect.top) + +// --------------------------------------------------------------------------- +// ZonesOverlay::GetAnimationAlpha (verbatim ramp) +// --------------------------------------------------------------------------- + +export function getAnimationAlpha(startedAtMs: number, nowMs: number, autoHide: boolean): number { + const millis = nowMs - startedAtMs + + if (autoHide && millis > FLASH_ZONES_DURATION_MILLIS) { + return 0 + } + + // Return a positive value to avoid hiding. + return Math.min(Math.max(millis / FADE_IN_DURATION_MILLIS, 0.001), 1) +} + +// --------------------------------------------------------------------------- +// Colors (Colors.cpp + FancyZones settings defaults) +// --------------------------------------------------------------------------- + +export interface ZoneColors { + primaryColor: string + borderColor: string + highlightColor: string + numberColor: string + /** 0..100, applied as fill alpha to BOTH primary and highlight fills. */ + highlightOpacity: number +} + +/** FancyZonesSettings defaults (custom-color mode). */ +export const FANCYZONES_DEFAULT_COLORS: ZoneColors = { + primaryColor: '#F5FCFF', + borderColor: '#FFFFFF', + highlightColor: '#008CFF', + numberColor: '#000000', + highlightOpacity: 50 +} + +/** + * Colors::GetZoneColors systemTheme branch, mapped to our design tokens: + * accent -> border + highlight, surface -> primary, number auto-contrast + * (CSS light-dark handles what the C++ does with the black-background check). + */ +export function systemThemeZoneColors(): ZoneColors { + return { + primaryColor: 'var(--ui-bg-editor)', + borderColor: 'var(--ui-accent)', + highlightColor: 'var(--ui-accent)', + numberColor: 'var(--ui-text-primary)', + highlightOpacity: 50 + } +} + +// --------------------------------------------------------------------------- +// ZoneSelectionAlgorithms (Layout.cpp, verbatim) +// --------------------------------------------------------------------------- + +function zoneSelectPriority( + zones: Map<string, EngineZone>, + capturedZones: string[], + compare: (a: EngineZone, b: EngineZone) => boolean +): string[] { + let chosen = 0 + + for (let i = 1; i < capturedZones.length; i++) { + if (compare(zones.get(capturedZones[i])!, zones.get(capturedZones[chosen])!)) { + chosen = i + } + } + + return [capturedZones[chosen]] +} + +function zoneSelectSubregion( + zones: Map<string, EngineZone>, + capturedZones: string[], + pt: Point, + sensitivityRadius: number +): string[] { + const expand = (rect: ZoneRect): ZoneRect => ({ + top: rect.top - sensitivityRadius / 2, + bottom: rect.bottom + sensitivityRadius / 2, + left: rect.left - sensitivityRadius / 2, + right: rect.right + sensitivityRadius / 2 + }) + + // Compute the overlapped rectangle. + let overlap = expand(zones.get(capturedZones[0])!.rect) + + for (let i = 1; i < capturedZones.length; i++) { + const current = expand(zones.get(capturedZones[i])!.rect) + overlap = { + top: Math.max(overlap.top, current.top), + left: Math.max(overlap.left, current.left), + bottom: Math.min(overlap.bottom, current.bottom), + right: Math.min(overlap.right, current.right) + } + } + + // Avoid division by zero. + const width = Math.max(overlap.right - overlap.left, 1) + const height = Math.max(overlap.bottom - overlap.top, 1) + + const verticalSplit = height > width + let zoneIndex: number + + if (verticalSplit) { + zoneIndex = Math.floor(((pt.y - overlap.top) * capturedZones.length) / height) + } else { + zoneIndex = Math.floor(((pt.x - overlap.left) * capturedZones.length) / width) + } + + zoneIndex = Math.min(Math.max(zoneIndex, 0), capturedZones.length - 1) + + return [capturedZones[zoneIndex]] +} + +function zoneSelectClosestCenter(zones: Map<string, EngineZone>, capturedZones: string[], pt: Point): string[] { + const getCenter = (zone: EngineZone): Point => ({ + x: (zone.rect.right + zone.rect.left) / 2, + y: (zone.rect.top + zone.rect.bottom) / 2 + }) + + const pointDifference = (a: Point, b: Point) => (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + const distanceFromCenter = (zone: EngineZone) => pointDifference(getCenter(zone), pt) + + const closerToCenter = (zone1: EngineZone, zone2: EngineZone) => { + if (pointDifference(getCenter(zone1), getCenter(zone2)) > OVERLAPPING_CENTERS_SENSITIVITY) { + return distanceFromCenter(zone1) < distanceFromCenter(zone2) + } + + return zoneArea(zone1) < zoneArea(zone2) + } + + return zoneSelectPriority(zones, capturedZones, closerToCenter) +} + +// --------------------------------------------------------------------------- +// Layout::ZonesFromPoint (verbatim) +// --------------------------------------------------------------------------- + +export function zonesFromPoint( + zoneList: EngineZone[], + pt: Point, + sensitivityRadius = DEFAULT_SENSITIVITY_RADIUS, + overlappingAlgorithm: OverlappingZonesAlgorithm = 'Smallest' +): string[] { + const zones = new Map(zoneList.map(z => [z.id, z])) + const capturedZones: string[] = [] + const strictlyCapturedZones: string[] = [] + + for (const zone of zoneList) { + const zoneRect = zone.rect + + if ( + zoneRect.left - sensitivityRadius <= pt.x && + pt.x <= zoneRect.right + sensitivityRadius && + zoneRect.top - sensitivityRadius <= pt.y && + pt.y <= zoneRect.bottom + sensitivityRadius + ) { + capturedZones.push(zone.id) + } + + if (zoneRect.left <= pt.x && pt.x < zoneRect.right && zoneRect.top <= pt.y && pt.y < zoneRect.bottom) { + strictlyCapturedZones.push(zone.id) + } + } + + // If only one zone is captured, but it's not strictly captured + // don't consider it as captured. + if (capturedZones.length === 1 && strictlyCapturedZones.length === 0) { + return [] + } + + // If captured zones do not overlap, return all of them. + // Otherwise, return one of them based on the chosen selection algorithm. + let overlap = false + + outer: for (let i = 0; i < capturedZones.length; i++) { + for (let j = i + 1; j < capturedZones.length; j++) { + const rectI = zones.get(capturedZones[i])!.rect + const rectJ = zones.get(capturedZones[j])!.rect + + if ( + Math.max(rectI.top, rectJ.top) + sensitivityRadius < Math.min(rectI.bottom, rectJ.bottom) && + Math.max(rectI.left, rectJ.left) + sensitivityRadius < Math.min(rectI.right, rectJ.right) + ) { + overlap = true + + break outer + } + } + } + + if (overlap) { + switch (overlappingAlgorithm) { + case 'Smallest': + return zoneSelectPriority(zones, capturedZones, (a, b) => zoneArea(a) < zoneArea(b)) + + case 'Largest': + return zoneSelectPriority(zones, capturedZones, (a, b) => zoneArea(a) > zoneArea(b)) + + case 'Positional': + return zoneSelectSubregion(zones, capturedZones, pt, sensitivityRadius) + + case 'ClosestCenter': + return zoneSelectClosestCenter(zones, capturedZones, pt) + } + } + + return capturedZones +} + +/** The single zone a drop lands in: ClosestCenter among the highlighted set. */ +export function primaryZone(zoneList: EngineZone[], highlighted: string[], pt: Point): string | null { + if (highlighted.length === 0) { + return null + } + + if (highlighted.length === 1) { + return highlighted[0] + } + + const zones = new Map(zoneList.map(z => [z.id, z])) + + return zoneSelectClosestCenter(zones, highlighted, pt)[0] ?? highlighted[0] +} + +// --------------------------------------------------------------------------- +// Layout::GetCombinedZoneRange (verbatim) +// --------------------------------------------------------------------------- + +export function getCombinedZoneRange(zoneList: EngineZone[], initialZones: string[], finalZones: string[]): string[] { + const zones = new Map(zoneList.map(z => [z.id, z])) + const combinedZones = [...new Set([...initialZones, ...finalZones])] + + let boundingRect: ZoneRect | null = null + + for (const zoneId of combinedZones) { + const zone = zones.get(zoneId) + + if (zone) { + const rect = zone.rect + + if (!boundingRect) { + boundingRect = { ...rect } + } else { + boundingRect.left = Math.min(boundingRect.left, rect.left) + boundingRect.top = Math.min(boundingRect.top, rect.top) + boundingRect.right = Math.max(boundingRect.right, rect.right) + boundingRect.bottom = Math.max(boundingRect.bottom, rect.bottom) + } + } + } + + const result: string[] = [] + + if (boundingRect) { + for (const zone of zoneList) { + const rect = zone.rect + + if ( + boundingRect.left <= rect.left && + rect.right <= boundingRect.right && + boundingRect.top <= rect.top && + rect.bottom <= boundingRect.bottom + ) { + result.push(zone.id) + } + } + } + + return result +} + +// --------------------------------------------------------------------------- +// HighlightedZones (HighlightedZones.cpp, verbatim state machine) +// --------------------------------------------------------------------------- + +export class HighlightedZones { + private highlightZone: string[] = [] + private initialHighlightZone: string[] = [] + + zones(): readonly string[] { + return this.highlightZone + } + + empty(): boolean { + return this.highlightZone.length === 0 + } + + /** Returns true when the highlight set changed. */ + update(zoneList: EngineZone[], point: Point, selectManyZones: boolean): boolean { + let highlightZone = zonesFromPoint(zoneList, point) + + if (selectManyZones) { + if (this.initialHighlightZone.length === 0) { + // First time. + this.initialHighlightZone = highlightZone + } else { + highlightZone = getCombinedZoneRange(zoneList, this.initialHighlightZone, highlightZone) + } + } else { + this.initialHighlightZone = [] + } + + const updated = + highlightZone.length !== this.highlightZone.length || highlightZone.some((z, i) => z !== this.highlightZone[i]) + + this.highlightZone = highlightZone + + return updated + } + + reset(): void { + this.highlightZone = [] + this.initialHighlightZone = [] + } +} diff --git a/apps/desktop/src/components/particles/particle-field.css b/apps/desktop/src/components/particles/particle-field.css new file mode 100644 index 000000000000..94bf2258aaee --- /dev/null +++ b/apps/desktop/src/components/particles/particle-field.css @@ -0,0 +1,126 @@ +/* ── Particle field ───────────────────────────────────────────────────────── + Reusable float-up emitter. Three nested layers, each owning ONE transform so + they never fight: + .particle — full-height track: vertical rise + fade (linear) + .particle__sway — horizontal weave + bank/tilt on its OWN period + (ease-in-out, alternating), so the path desyncs from + the rise and never repeats — the organic-motion trick + .particle__glyph — one-shot springy pop-in scale + The track is full-height and anchored at the field bottom, so the rise's + `translateY(-rise%)` measures against the field height, not the glyph's. All + tuning arrives as inline `--particle-*` custom properties. */ +.particle-field { + pointer-events: none; + overflow: visible; +} + +.particle { + position: absolute; + inset-block: 0; + left: var(--particle-left); + display: flex; + align-items: flex-end; + justify-content: center; + width: var(--particle-size); + margin-left: calc(var(--particle-size) / -2); + will-change: transform, opacity; + animation: particle-rise var(--particle-duration) linear var(--particle-delay) both; +} + +.particle__sway { + display: block; + will-change: transform; + animation: particle-sway var(--particle-sway-duration) ease-in-out var(--particle-sway-delay) + infinite alternate; +} + +.particle__glyph { + display: block; + width: var(--particle-size); + color: var(--particle-color); + will-change: transform; + animation: particle-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1) var(--particle-delay) both; +} + +.particle__glyph > svg { + display: block; + width: 100%; + height: auto; +} + +/* Rise: straight up the field, holds opacity through most of the climb, fades + near the top. */ +@keyframes particle-rise { + 0% { + opacity: 0; + transform: translate3d(0, 0, 0); + } + + 6% { + opacity: 1; + transform: translate3d(0, calc(var(--particle-rise) * -0.06%), 0); + } + + 70% { + opacity: 1; + transform: translate3d(0, calc(var(--particle-rise) * -0.7%), 0); + } + + 100% { + opacity: 0; + transform: translate3d(0, calc(var(--particle-rise) * -1%), 0); + } +} + +/* Sway + bank: swings left↔right and tilts INTO the direction of travel, like a + leaf on a breeze. Runs on its own clock. */ +@keyframes particle-sway { + from { + transform: translateX(calc(var(--particle-sway) * -1)) rotate(calc(var(--particle-bank) * -1)); + } + + to { + transform: translateX(var(--particle-sway)) rotate(var(--particle-bank)); + } +} + +/* Pop: a springy scale-in overshoot (the bezier is a spring, not a path wobble). */ +@keyframes particle-pop { + 0% { + transform: scale(0.3); + } + + 100% { + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .particle { + inset-block: auto 0; + height: var(--particle-size); + animation: particle-flash 650ms ease-out var(--particle-delay) both; + } + + .particle__sway, + .particle__glyph { + animation: none; + } + + @keyframes particle-flash { + 0% { + opacity: 0; + transform: translate3d(0, 0, 0) scale(0.6); + } + + 20% { + opacity: 1; + transform: translate3d(0, -0.5rem, 0) scale(1.1); + } + + 100% { + opacity: 0; + transform: translate3d(0, -1rem, 0) scale(1); + } + } +} diff --git a/apps/desktop/src/components/particles/particle-field.tsx b/apps/desktop/src/components/particles/particle-field.tsx new file mode 100644 index 000000000000..a7e70c933c67 --- /dev/null +++ b/apps/desktop/src/components/particles/particle-field.tsx @@ -0,0 +1,204 @@ +import './particle-field.css' + +import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' + +import { cn } from '@/lib/utils' + +/** + * Reusable float-up particle emitter. It owns the motion (rise + organic sway + + * springy pop) and lifecycle (staggered burst, lifetime, cleanup); callers just + * hand it a `glyph` (any element using `currentColor`) and `colors`, then place + * it with `className` / `style`. See {@link VibeHearts} for the chat-hearts use. + */ + +type Range = readonly [min: number, max: number] + +const rand = ([min, max]: Range) => min + Math.random() * (max - min) +/** Sample a range along `t` (0→min, 1→max) — couples travel/lifetime to `life`. */ +const lerp = ([min, max]: Range, t: number) => min + (max - min) * t + +export interface ParticleFieldConfig { + /** Particles per burst when `burst()` is called without a count. */ + count: number + /** Window (ms) over which a burst releases its particles — small = one poof. */ + spawnWindowMs: number + /** Glyph edge size (px), uniform. */ + size: Range + /** Vertical travel before fade-out (% of field height), `life`-biased. */ + rise: Range + /** Rise duration (ms), `life`-biased so short-lived particles also rise less. */ + duration: Range + /** Sway amplitude each side of center (px), uniform. */ + swayAmp: Range + /** Peak tilt into the sway (deg), uniform. */ + bank: Range + /** Sway period (ms), uniform — independent of the rise so paths never repeat. */ + swayDuration: Range + /** Cap on simultaneously-alive particles. */ + maxAlive: number +} + +export const DEFAULT_PARTICLE_CONFIG: ParticleFieldConfig = { + count: 12, + spawnWindowMs: 550, + size: [6, 13], + rise: [6.75, 15.75], + duration: [320, 700], + swayAmp: [9, 24], + bank: [7, 16], + swayDuration: [1300, 2800], + maxAlive: 200 +} + +export interface ParticleEmitter { + /** Fire a burst (defaults to the field's configured `count`). */ + burst: (count?: number) => void + /** Internal: field subscription. */ + subscribe: (fn: (count?: number) => void) => () => void +} + +/** Create an emitter handle. `burst()` is safe to call from anywhere. */ +export function createParticleEmitter(): ParticleEmitter { + const listeners = new Set<(count?: number) => void>() + + return { + burst: count => listeners.forEach(fn => fn(count)), + subscribe: fn => { + listeners.add(fn) + + return () => void listeners.delete(fn) + } + } +} + +interface Particle { + id: number + leftPct: number + size: number + color: string + delayMs: number + durationMs: number + rise: number + swayAmp: number + bank: number + swayDurationMs: number + swayDelayMs: number +} + +let nextId = 1 + +function spawn(cfg: ParticleFieldConfig, colors: readonly string[]): Particle { + // Short-lived particles fade out lower; a few live longer and rise higher. + const life = Math.random() ** 1.7 + const swayDurationMs = Math.round(rand(cfg.swayDuration)) + + return { + id: nextId++, + // Spread edge to edge across the lane, not clustered near center. + leftPct: 4 + Math.random() * 92, + size: rand(cfg.size), + color: colors[Math.floor(Math.random() * colors.length)]!, + delayMs: Math.round(Math.random() * 120), + durationMs: Math.round(lerp(cfg.duration, life)), + rise: lerp(cfg.rise, life), + swayAmp: rand(cfg.swayAmp), + bank: rand(cfg.bank), + swayDurationMs, + // Negative delay drops each particle in mid-swing (desynced phases). + swayDelayMs: -Math.round(Math.random() * swayDurationMs) + } +} + +const prefersReducedMotion = () => + typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) + +export interface ParticleFieldProps { + emitter: ParticleEmitter + /** Any element that paints with `currentColor` (SVG, glyph, …). */ + glyph: ReactNode + colors: readonly string[] + config?: Partial<ParticleFieldConfig> + className?: string + style?: CSSProperties +} + +export function ParticleField({ emitter, glyph, colors, config, className, style }: ParticleFieldProps) { + const cfg = useMemo(() => ({ ...DEFAULT_PARTICLE_CONFIG, ...config }), [config]) + const [particles, setParticles] = useState<Particle[]>([]) + const timers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set()) + + useEffect(() => { + const pool = timers.current + const add = () => setParticles(prev => [...prev, spawn(cfg, colors)].slice(-cfg.maxAlive)) + + // Release a burst across a tight window so it reads as one poof, each node + // with its own random birth time; reduced motion gets a single flash. + const onBurst = (count?: number) => { + const n = Math.max(1, Math.min(cfg.maxAlive, Math.round(count ?? cfg.count))) + + if (prefersReducedMotion()) { + add() + + return + } + + for (let i = 0; i < n; i++) { + const timer = setTimeout(() => { + pool.delete(timer) + add() + }, Math.random() * cfg.spawnWindowMs) + + pool.add(timer) + } + } + + const unsubscribe = emitter.subscribe(onBurst) + + return () => { + unsubscribe() + pool.forEach(clearTimeout) + pool.clear() + } + }, [cfg, colors, emitter]) + + const remove = (id: number) => setParticles(prev => prev.filter(p => p.id !== id)) + + if (particles.length === 0) { + return null + } + + return ( + <div aria-hidden className={cn('particle-field', className)} style={style}> + {particles.map(p => ( + <span + className="particle" + key={p.id} + // Retire on the RISE track only (sway is infinite, pop is shorter). + onAnimationEnd={e => { + if (e.animationName === 'particle-rise' || e.animationName === 'particle-flash') { + remove(p.id) + } + }} + style={ + { + '--particle-left': `${p.leftPct}%`, + '--particle-size': `${p.size}px`, + '--particle-color': p.color, + '--particle-delay': `${p.delayMs}ms`, + '--particle-duration': `${p.durationMs}ms`, + '--particle-rise': p.rise, + '--particle-sway': `${p.swayAmp}px`, + '--particle-bank': `${p.bank}deg`, + '--particle-sway-duration': `${p.swayDurationMs}ms`, + '--particle-sway-delay': `${p.swayDelayMs}ms` + } as CSSProperties + } + > + <span className="particle__sway"> + <span className="particle__glyph">{glyph}</span> + </span> + </span> + ))} + </div> + ) +} diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 5ead9838dc79..399ed1bf4857 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { useOnProfileSwitch } from '@/app/hooks/use-on-profile-switch' import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active' +import { PetHeartField } from '@/components/chat/vibe-hearts' import { persistString, storedString } from '@/lib/storage' import { $petAtRest, @@ -220,7 +221,7 @@ export function FloatingPet() { }) // Wire the overlay control channel once, only in the primary window — the - // pop-out overlay belongs to it (main.cjs positions it against the main + // pop-out overlay belongs to it (main.ts positions it against the main // window and routes control messages back to it). useEffect(() => { if (isSecondaryWindow()) { @@ -447,6 +448,9 @@ export function FloatingPet() { > <PetSprite info={info} rowOverride={walk.row} /> </div> + {/* Hearts puff off the pet; its celebrate ("yay"/jump) pose is driven by + burstVibeHearts's router. */} + <PetHeartField petH={petH} petW={petW} /> </div> ) } diff --git a/apps/desktop/src/components/prompt-overlays.test.tsx b/apps/desktop/src/components/prompt-overlays.test.tsx new file mode 100644 index 000000000000..dd9cdb7ec0b4 --- /dev/null +++ b/apps/desktop/src/components/prompt-overlays.test.tsx @@ -0,0 +1,67 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' +import { $gateway } from '@/store/gateway' +import { notifyError } from '@/store/notifications' +import { $secretRequest, $sudoRequest, clearAllPrompts, setSecretRequest, setSudoRequest } from '@/store/prompts' +import { $activeSessionId } from '@/store/session' + +import { PromptOverlays } from './prompt-overlays' + +vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) +vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() })) + +function renderPrompts(sessionId: string | null = 's1') { + render( + <I18nProvider configClient={null}> + <PromptOverlays sessionId={sessionId} /> + </I18nProvider> + ) +} + +afterEach(() => { + cleanup() + clearAllPrompts() + $activeSessionId.set(null) + $gateway.set(null) + vi.clearAllMocks() +}) + +describe('PromptOverlays', () => { + it('dismisses a stale sudo dialog when the gateway no longer has the password request', async () => { + const request = vi.fn().mockRejectedValue(new Error('no pending password request')) + + $activeSessionId.set('s1') + $gateway.set({ request } as never) + setSudoRequest({ requestId: 'sudo-1', sessionId: 's1' }) + + renderPrompts() + + expect(screen.getByText('Administrator password')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect($sudoRequest.get()).toBeNull()) + expect(request).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' }) + expect(notifyError).not.toHaveBeenCalled() + }) + + it('dismisses a stale secret dialog when the gateway no longer has the value request', async () => { + const request = vi.fn().mockRejectedValue(new Error('no pending value request')) + + $activeSessionId.set('s1') + $gateway.set({ request } as never) + setSecretRequest({ envVar: 'TEST_SECRET', prompt: 'Paste a secret', requestId: 'secret-1', sessionId: 's1' }) + + renderPrompts() + + expect(screen.getByText('TEST_SECRET')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect($secretRequest.get()).toBeNull()) + expect(request).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' }) + expect(notifyError).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx index a43303e1ced3..c64656e88ef6 100644 --- a/apps/desktop/src/components/prompt-overlays.tsx +++ b/apps/desktop/src/components/prompt-overlays.tsx @@ -1,7 +1,7 @@ 'use client' import { useStore } from '@nanostores/react' -import { type FormEvent, useCallback, useEffect, useState } from 'react' +import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react' import { PendingApprovalFallback } from '@/components/assistant-ui/tool/approval' import { Button } from '@/components/ui/button' @@ -15,11 +15,17 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { useI18n } from '@/i18n' +import { isMissingPendingPromptRequest } from '@/lib/gateway-rpc' import { triggerHaptic } from '@/lib/haptics' import { KeyRound, Loader2, Lock } from '@/lib/icons' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' -import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } from '@/store/prompts' +import { + clearSecretRequest, + clearSudoRequest, + sessionSecretRequest, + sessionSudoRequest +} from '@/store/prompts' // Renders the modal mid-turn prompts the gateway raises and waits on: sudo // password and skill secret capture. Dangerous-command / execute_code approval @@ -34,10 +40,11 @@ import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } fr // fire a second `*.respond` alongside onOpenChange (double-send) or block the // backdrop-dismiss path. -function SudoDialog() { +function SudoDialog({ sessionId }: { sessionId: string | null }) { const { t } = useI18n() const copy = t.prompts - const request = useStore($sudoRequest) + const $request = useMemo(() => sessionSudoRequest(sessionId), [sessionId]) + const request = useStore($request) const gateway = useStore($gateway) const [password, setPassword] = useState('') const [submitting, setSubmitting] = useState(false) @@ -69,6 +76,12 @@ function SudoDialog() { triggerHaptic('submit') clearSudoRequest(request.sessionId, request.requestId) } catch (error) { + if (isMissingPendingPromptRequest(error, 'password')) { + clearSudoRequest(request.sessionId, request.requestId) + + return + } + notifyError(error, copy.sudoSendFailed) setSubmitting(false) } @@ -130,10 +143,11 @@ function SudoDialog() { ) } -function SecretDialog() { +function SecretDialog({ sessionId }: { sessionId: string | null }) { const { t } = useI18n() const copy = t.prompts - const request = useStore($secretRequest) + const $request = useMemo(() => sessionSecretRequest(sessionId), [sessionId]) + const request = useStore($request) const gateway = useStore($gateway) const [value, setValue] = useState('') const [submitting, setSubmitting] = useState(false) @@ -165,6 +179,12 @@ function SecretDialog() { triggerHaptic('submit') clearSecretRequest(request.sessionId, request.requestId) } catch (error) { + if (isMissingPendingPromptRequest(error, 'value')) { + clearSecretRequest(request.sessionId, request.requestId) + + return + } + notifyError(error, copy.secretSendFailed) setSubmitting(false) } @@ -224,12 +244,15 @@ function SecretDialog() { ) } -export function PromptOverlays() { +/** Mid-turn prompt surfaces for ONE session. Mounted by both the primary chat + * and each tile with its own session id, so a background/tiled session's + * blocking prompt renders instead of silently stalling. */ +export function PromptOverlays({ sessionId }: { sessionId: string | null }) { return ( <> <PendingApprovalFallback /> - <SudoDialog /> - <SecretDialog /> + <SudoDialog sessionId={sessionId} /> + <SecretDialog sessionId={sessionId} /> </> ) } diff --git a/apps/desktop/src/components/ui/badge.tsx b/apps/desktop/src/components/ui/badge.tsx index 6f286c3fde7d..c4e46e52361d 100644 --- a/apps/desktop/src/components/ui/badge.tsx +++ b/apps/desktop/src/components/ui/badge.tsx @@ -7,7 +7,7 @@ import { cn } from '@/lib/utils' // Small status/metadata tag. App radius (not a full pill); tones map to the // shared accent/muted/destructive surfaces so badges read consistently. const badgeVariants = cva( - 'inline-flex w-fit shrink-0 items-center gap-1 rounded-[3px] px-1.5 py-0.5 text-[0.65rem] font-medium leading-none whitespace-nowrap [&_svg]:size-3 [&_svg]:pointer-events-none', + 'inline-flex w-fit shrink-0 items-center gap-1 rounded-[3px] font-medium leading-none whitespace-nowrap [&_svg]:pointer-events-none', { variants: { variant: { @@ -16,9 +16,13 @@ const badgeVariants = cva( warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300', destructive: 'bg-destructive/10 text-destructive', outline: 'border border-(--ui-stroke-secondary) text-muted-foreground' + }, + size: { + default: 'px-1.5 py-0.5 text-[0.65rem] [&_svg]:size-3', + xs: 'px-1 py-px text-[0.6rem] [&_svg]:size-2.5' } }, - defaultVariants: { variant: 'default' } + defaultVariants: { variant: 'default', size: 'default' } } ) @@ -26,10 +30,10 @@ export interface BadgeProps extends React.ComponentProps<'span'>, VariantProps<t asChild?: boolean } -export function Badge({ asChild = false, className, variant, ...props }: BadgeProps) { +export function Badge({ asChild = false, className, size, variant, ...props }: BadgeProps) { const Comp = asChild ? Slot.Root : 'span' - return <Comp className={cn(badgeVariants({ variant }), className)} data-slot="badge" {...props} /> + return <Comp className={cn(badgeVariants({ size, variant }), className)} data-slot="badge" {...props} /> } export { badgeVariants } diff --git a/apps/desktop/src/components/ui/button.tsx b/apps/desktop/src/components/ui/button.tsx index 06abd4b7945c..10a107727a51 100644 --- a/apps/desktop/src/components/ui/button.tsx +++ b/apps/desktop/src/components/ui/button.tsx @@ -53,6 +53,14 @@ const buttonVariants = cva( 'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]' } }, + compoundVariants: [ + // textStrong is a boxless link — size variants still inject px-*; strip + // inline padding so the underline sits flush with the label. + { + variant: 'textStrong', + class: 'px-0 has-[>svg]:px-0' + } + ], defaultVariants: { variant: 'default', size: 'default' diff --git a/apps/desktop/src/components/ui/context-menu.tsx b/apps/desktop/src/components/ui/context-menu.tsx index 0849efdd53c6..1652d68b9f70 100644 --- a/apps/desktop/src/components/ui/context-menu.tsx +++ b/apps/desktop/src/components/ui/context-menu.tsx @@ -113,16 +113,30 @@ function ContextMenuSubTrigger({ ) } -function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) { +function ContextMenuSubContent({ + className, + collisionPadding = 8, + ...props +}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) { return ( - <ContextMenuPrimitive.SubContent - className={cn( - 'z-50 min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', - className - )} - data-slot="context-menu-sub-content" - {...props} - /> + // Portal the submenu out of the parent Content so it escapes that Content's + // `overflow` clip — without this a submenu opening from a scrollable / + // overflow-hidden menu gets visually cut off at the parent's edges. Radix + // Popper still anchors it to the SubTrigger, so portaling is safe. Mirrors + // DropdownMenuSubContent. + <ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.SubContent + className={cn( + // `max-h-80` (not the Radix available-height var, which is published + // only on Content) so a long submenu scrolls instead of collapsing. + 'dt-portal-scrollbar z-50 max-h-80 min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', + className + )} + collisionPadding={collisionPadding} + data-slot="context-menu-sub-content" + {...props} + /> + </ContextMenuPrimitive.Portal> ) } diff --git a/apps/desktop/src/components/ui/copy-button.tsx b/apps/desktop/src/components/ui/copy-button.tsx index ff7663ff94a2..cee6edcde884 100644 --- a/apps/desktop/src/components/ui/copy-button.tsx +++ b/apps/desktop/src/components/ui/copy-button.tsx @@ -233,5 +233,11 @@ export function CopyButton({ ) // Only icon-only buttons need a tooltip; the text variant already shows its label. - return appearance === 'icon' ? <Tip label={feedbackLabel} side={side ?? 'bottom'}>{button}</Tip> : button + return appearance === 'icon' ? ( + <Tip label={feedbackLabel} side={side ?? 'bottom'}> + {button} + </Tip> + ) : ( + button + ) } diff --git a/apps/desktop/src/components/ui/decode-text.tsx b/apps/desktop/src/components/ui/decode-text.tsx new file mode 100644 index 000000000000..e5bc470ab076 --- /dev/null +++ b/apps/desktop/src/components/ui/decode-text.tsx @@ -0,0 +1,113 @@ +import { type ComponentProps, useEffect, useState } from 'react' + +import { cn } from '@/lib/utils' + +/** + * DecodeText — the "CONNECTING" scramble-decode effect as a reusable + * primitive (extracted from gateway-connecting-overlay.tsx; same mechanics): + * + * - Even-weight mono ascii charset so cycling glyphs never jump width + * (matches the nousnet-web download-button decode effect). + * - Decode resolves half a character per 45ms tick; when fully resolved it + * holds for 16 ticks, then (in loop mode) replays. + * - The first `prefix` characters NEVER scramble — split at render level so + * no timer logic (even a stale HMR one) can garble them. + * - Optional blinking dither-cursor square. + * + * Typography (mono, small, uppercase, wide tracking) is baked in; color comes + * from the caller via className/text color so the same primitive works on the + * boot overlay (--theme-primary) and quiet surfaces (--ui-text-quaternary). + */ + +export const DECODE_SCRAMBLE_CHARS = '/\\|-_=+<>~:*' +const TICK_MS = 45 +const HOLD_TICKS = 16 + +function scrambled(tail: string, resolvedCount: number): string { + return Array.from(tail, (ch, i) => + ch === ' ' || i < resolvedCount ? ch : DECODE_SCRAMBLE_CHARS[(Math.random() * DECODE_SCRAMBLE_CHARS.length) | 0] + ).join('') +} + +export interface DecodeTextProps extends Omit<ComponentProps<'span'>, 'prefix'> { + text: string + /** Leading character count that stays legible at all times. */ + prefix?: number + /** Run the decode. When false, renders the plain resolved text (used to + * freeze the word during exit choreography). */ + active?: boolean + /** Replay after the hold, or resolve once and stop. */ + loop?: boolean + /** Blinking dither-cursor square after the text. */ + cursor?: boolean +} + +export function DecodeText({ + active = true, + className, + cursor = false, + loop = true, + prefix = 0, + text, + ...props +}: DecodeTextProps) { + const staticPrefix = text.slice(0, prefix) + const tailText = text.slice(prefix) + const [tail, setTail] = useState(tailText) + + useEffect(() => { + if (!active) { + setTail(tailText) + + return + } + + let resolved = 0 + let hold = 0 + + const id = window.setInterval(() => { + if (resolved >= tailText.length) { + hold += 1 + + if (hold > HOLD_TICKS) { + if (loop) { + resolved = 0 + hold = 0 + } else { + window.clearInterval(id) + } + } + + setTail(tailText) + + return + } + + resolved += 0.5 + setTail(scrambled(tailText, Math.floor(resolved))) + }, TICK_MS) + + return () => window.clearInterval(id) + }, [active, loop, tailText]) + + return ( + <span + className={cn( + 'inline-flex items-center font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums', + className + )} + {...props} + > + {cursor && <style>{'@keyframes decode-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>} + {staticPrefix} + {tail} + {cursor && ( + <span + aria-hidden="true" + className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]" + style={{ animation: 'decode-cursor 1s step-end infinite' }} + /> + )} + </span> + ) +} diff --git a/apps/desktop/src/components/ui/drop-affordance.tsx b/apps/desktop/src/components/ui/drop-affordance.tsx new file mode 100644 index 000000000000..de34aac4924d --- /dev/null +++ b/apps/desktop/src/components/ui/drop-affordance.tsx @@ -0,0 +1,11 @@ +/** + * Shared drag-and-drop visual language — ONE dashed accent sheet, used by every + * drop affordance (the composer file/session overlay, the layout zone targets) + * so "you can drop here" reads identically everywhere. + */ + +/** The sheet: a dashed region marking where a drop would land. */ +export const DROP_SHEET_CLASS = 'rounded-2xl border-2 border-dashed' + +/** Soft blur for the LIVE sheet only — idle outlines must not fog the app. */ +export const DROP_SHEET_BLUR_CLASS = 'backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]' diff --git a/apps/desktop/src/components/ui/pane-tab.test.tsx b/apps/desktop/src/components/ui/pane-tab.test.tsx new file mode 100644 index 000000000000..c36f03fb8ada --- /dev/null +++ b/apps/desktop/src/components/ui/pane-tab.test.tsx @@ -0,0 +1,92 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { PaneTab, PaneTabLabel } from './pane-tab' + +afterEach(cleanup) + +describe('PaneTab close gestures', () => { + it('middle-click (button 1) closes', () => { + const onClose = vi.fn() + render( + <PaneTab onClose={onClose}> + <PaneTabLabel>tab</PaneTabLabel> + </PaneTab> + ) + + fireEvent(screen.getByText('tab'), new MouseEvent('auxclick', { bubbles: true, button: 1 })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('⌘-click (metaKey + button 0) closes — the Mac middle-click equivalent', () => { + const onClose = vi.fn() + render( + <PaneTab onClose={onClose}> + <PaneTabLabel>tab</PaneTabLabel> + </PaneTab> + ) + + fireEvent.pointerDown(screen.getByText('tab'), { button: 0, metaKey: true }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('⌘-click preempts the shell drag/activate pointerdown handler', () => { + const onClose = vi.fn() + const onPointerDown = vi.fn() + render( + <PaneTab onClose={onClose} onPointerDown={onPointerDown}> + <PaneTabLabel>tab</PaneTabLabel> + </PaneTab> + ) + + fireEvent.pointerDown(screen.getByText('tab'), { button: 0, metaKey: true }) + expect(onClose).toHaveBeenCalledTimes(1) + expect(onPointerDown).not.toHaveBeenCalled() + }) + + it('⌘-click swallows the follow-up activation click (capture phase)', () => { + const onClose = vi.fn() + const onActivate = vi.fn() + render( + <PaneTab onClose={onClose}> + <PaneTabLabel as="button" onClick={onActivate}> + tab + </PaneTabLabel> + </PaneTab> + ) + + fireEvent.click(screen.getByText('tab'), { button: 0, metaKey: true }) + expect(onActivate).not.toHaveBeenCalled() + }) + + it('plain left-click neither closes nor blocks activation', () => { + const onClose = vi.fn() + const onActivate = vi.fn() + const onPointerDown = vi.fn() + render( + <PaneTab onClose={onClose} onPointerDown={onPointerDown}> + <PaneTabLabel as="button" onClick={onActivate}> + tab + </PaneTabLabel> + </PaneTab> + ) + + fireEvent.pointerDown(screen.getByText('tab'), { button: 0 }) + fireEvent.click(screen.getByText('tab'), { button: 0 }) + expect(onClose).not.toHaveBeenCalled() + expect(onPointerDown).toHaveBeenCalledTimes(1) + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('does nothing without an onClose (uncloseable workspace tab)', () => { + const onPointerDown = vi.fn() + render( + <PaneTab onPointerDown={onPointerDown}> + <PaneTabLabel>tab</PaneTabLabel> + </PaneTab> + ) + + fireEvent.pointerDown(screen.getByText('tab'), { button: 0, metaKey: true }) + expect(onPointerDown).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx new file mode 100644 index 000000000000..da8baae2aec9 --- /dev/null +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -0,0 +1,170 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +/** Inset bottom stroke for a horizontal tab strip — titlebar color, cut by the active tab. */ +export const PANE_TAB_STRIP_LINE = 'shadow-[inset_0_-1px_0_var(--ui-stroke-tertiary)]' + +/** Inset stroke for a vertical tab rail — content-facing edge. */ +export const PANE_TAB_STRIP_LINE_LEFT = 'shadow-[inset_1px_0_0_var(--ui-stroke-tertiary)]' +export const PANE_TAB_STRIP_LINE_RIGHT = 'shadow-[inset_-1px_0_0_var(--ui-stroke-tertiary)]' + +const TAB = + 'group/tab relative flex shrink-0 items-center border-transparent bg-(--tab-bg) text-[0.6875rem] font-medium [-webkit-app-region:no-drag]' + +const TAB_HORIZONTAL = 'h-full min-w-0 max-w-48 border-b not-first:border-l not-first:border-l-(--ui-stroke-quaternary)' + +const TAB_VERTICAL = + 'w-full max-h-48 justify-center not-first:border-t not-first:border-t-(--ui-stroke-quaternary) [writing-mode:vertical-rl]' + +const TAB_ACTIVE = 'text-foreground [--tab-bg:var(--pane-tab-active-bg,var(--ui-editor-surface-background))]' + +// Inactive = gutter. Hover = 4% translucent wash (VS Code/GitHub alpha hover), +// not an opaque recolor — and never touch borders. +const TAB_IDLE = + 'text-(--ui-text-tertiary) [--tab-bg:var(--pane-tab-strip-bg,var(--theme-card-seed))] hover:shadow-[inset_0_0_0_100vmax_color-mix(in_srgb,var(--ui-base)_4%,transparent)] hover:text-(--ui-text-secondary)' + +interface PaneTabProps extends React.ComponentProps<'div'> { + active?: boolean + dirty?: boolean + /** Close gesture, no hover X (too easy to hit on small tabs): middle-click, + * or ⌘-click as the trackpad-friendly Mac equivalent. */ + onClose?: () => void + /** Vertical rail form (collapsed sidebar zones). */ + vertical?: boolean + /** Content-facing edge of a vertical rail — the strip line the active tab cuts. */ + side?: 'left' | 'right' +} + +/** ⌘-click (metaKey + primary button) — the Mac has no middle button, so this + * is the trackpad equivalent of middle-click-to-close. Guarded on metaKey so + * it never collides with left-click (activate/drag) or ⌃-click (macOS context + * menu). */ +const isMetaClose = (event: { button: number; metaKey: boolean }) => event.button === 0 && event.metaKey + +/** + * Editor tab shell — preview rail + zone headers + collapsed vertical rails. + * + * Strip sets `--pane-tab-active-bg` (content surface) and `--pane-tab-strip-bg` + * (gutter; prefer `--theme-card-seed` = VS Code `tab.inactiveBackground`). + * Active merges into content; inactive sits flush in the gutter. + */ +export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function PaneTab( + { + active = false, + dirty = false, + onClose, + onAuxClick, + onMouseDown, + onPointerDown, + onClickCapture, + vertical = false, + side = 'left', + children, + className, + ...props + }, + ref +) { + // Content-facing edge: horizontal cuts the bottom strip line; vertical cuts + // the side that faces the editor (left rail → right edge, right rail → left). + const edge = vertical ? (side === 'right' ? 'border-l' : 'border-r') : 'border-b' + + return ( + <div + className={cn( + TAB, + vertical ? TAB_VERTICAL : TAB_HORIZONTAL, + edge, + active ? TAB_ACTIVE : cn(TAB_IDLE, `${edge}-(--ui-stroke-tertiary)`), + className + )} + data-active={active} + data-vertical={vertical || undefined} + onAuxClick={event => { + // Middle-click closes (browser/IDE). Swallow mousedown so Chromium + // doesn't autoscroll. + if (onClose && event.button === 1) { + event.preventDefault() + onClose() + } + + onAuxClick?.(event) + }} + onClickCapture={event => { + // Sites whose tab activates on the label's own onClick (the preview + // rail) fire it AFTER our pointerdown close — swallow that stray click + // in the capture phase so it can't re-select the just-closed tab. + if (onClose && isMetaClose(event)) { + event.preventDefault() + event.stopPropagation() + } + + onClickCapture?.(event) + }} + onMouseDown={event => { + if (onClose && event.button === 1) { + event.preventDefault() + } + + onMouseDown?.(event) + }} + onPointerDown={event => { + // ⌘-click closes. Preempt here — the tab strips activate/drag on + // pointerdown (drag-session onTap), so we must claim the press before + // the shell's own handler starts a drag, and skip it entirely. + if (onClose && isMetaClose(event)) { + event.preventDefault() + event.stopPropagation() + onClose() + + return + } + + onPointerDown?.(event) + }} + ref={ref} + {...props} + > + {children} + {dirty && ( + <span + aria-hidden + className={cn( + 'pointer-events-none absolute grid size-4 place-items-center', + vertical ? 'bottom-1.5 left-1/2 -translate-x-1/2' : 'right-1.5 top-1/2 -translate-y-1/2' + )} + > + <span className="size-2 rounded-full bg-amber-500 shadow-[0_0_0_2px_var(--tab-bg),0_1px_2px_rgba(0,0,0,0.45)] dark:bg-amber-400" /> + </span> + )} + </div> + ) +}) + +interface PaneTabLabelProps extends React.ComponentProps<'button'> { + /** `button` when the label is the activation target (preview rail); + * default `span` defers to the shell (zone drag/activate). */ + as?: 'button' | 'span' +} + +/** Truncating label inside a `PaneTab`. `className` merges into the text span + * (e.g. `normal-case tracking-normal` for filenames). */ +export const PaneTabLabel = React.forwardRef<HTMLElement, PaneTabLabelProps>(function PaneTabLabel( + { as = 'span', className, children, ...props }, + ref +) { + const Comp = as as React.ElementType + + return ( + <Comp + className="flex h-full min-w-0 max-w-full items-center overflow-hidden px-2 text-left outline-none group-data-[vertical]/tab:h-auto group-data-[vertical]/tab:w-full group-data-[vertical]/tab:justify-center group-data-[vertical]/tab:py-2" + ref={ref} + {...props} + > + <span className={cn('block min-w-0 truncate text-[9px] font-medium tracking-wide uppercase', className)}> + {children} + </span> + </Comp> + ) +}) diff --git a/apps/desktop/src/components/ui/title-menu-trigger.tsx b/apps/desktop/src/components/ui/title-menu-trigger.tsx new file mode 100644 index 000000000000..9705b9b61f2f --- /dev/null +++ b/apps/desktop/src/components/ui/title-menu-trigger.tsx @@ -0,0 +1,33 @@ +import type * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +/** + * Compact "Label ▾" chrome trigger. Domain-agnostic — drop in as the child of + * `DropdownMenuTrigger asChild` (or any asChild menu trigger). Sessions, + * projects, filters, etc. own their menus; this only owns the trigger look. + */ +export function TitleMenuTrigger({ + children, + className, + ...props +}: Omit<React.ComponentProps<typeof Button>, 'children' | 'size' | 'variant'> & { + children: React.ReactNode +}) { + return ( + <Button + className={cn( + 'pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]', + className + )} + type="button" + variant="ghost" + {...props} + > + <span className="min-w-0 flex-1 truncate text-[0.75rem] font-medium leading-none">{children}</span> + <Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" /> + </Button> + ) +} diff --git a/apps/desktop/src/components/ui/tooltip.test.tsx b/apps/desktop/src/components/ui/tooltip.test.tsx new file mode 100644 index 000000000000..ead0cda08fb0 --- /dev/null +++ b/apps/desktop/src/components/ui/tooltip.test.tsx @@ -0,0 +1,74 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { Tip, TipHintLabel } from './tooltip' + +describe('Tip', () => { + afterEach(() => { + cleanup() + }) + + it('shows on pointer enter and dismisses on pointer leave', async () => { + render( + <Tip label="Layout editor — ⌘-click resets the layout"> + <button type="button">layout</button> + </Tip> + ) + + const trigger = screen.getByRole('button', { name: 'layout' }) + + fireEvent.pointerMove(trigger, { pointerType: 'mouse' }) + expect((await screen.findByRole('tooltip')).textContent).toContain('Layout editor — ⌘-click resets the layout') + + fireEvent.pointerLeave(trigger) + await waitFor(() => { + expect(screen.queryByRole('tooltip')).toBeNull() + }) + }) + + it('renders the child alone when label is empty', () => { + render( + <Tip label=""> + <button type="button">bare</button> + </Tip> + ) + + expect(screen.getByRole('button', { name: 'bare' })).toBeTruthy() + expect(screen.queryByRole('tooltip')).toBeNull() + }) + + it('guards a block-level label child via the decoration wrapper class', async () => { + render( + <Tip label={<span className="flex items-center gap-2">broken label</span>}> + <button type="button">trigger</button> + </Tip> + ) + + fireEvent.pointerMove(screen.getByRole('button', { name: 'trigger' }), { pointerType: 'mouse' }) + await screen.findByRole('tooltip') + + // jsdom applies no real Tailwind, so assert the guarding class is present on + // the decoration wrapper — that's what forces any direct child inline-flex + // in a browser (#62022). + const decoration = document.querySelector<HTMLElement>('[data-slot="tooltip-content"]')?.firstElementChild + + expect(decoration?.className).toMatch(/\[&>\*\]:!inline-flex/) + }) +}) + +describe('TipHintLabel', () => { + afterEach(() => { + cleanup() + }) + + it('renders inline-flex with a hint and plain text without one', () => { + const { rerender } = render(<TipHintLabel hint="Ctrl+`" text="PowerShell" />) + const withHint = screen.getByText('PowerShell').parentElement + + expect(withHint?.classList.contains('inline-flex')).toBe(true) + expect(withHint?.classList.contains('flex')).toBe(false) + + rerender(<TipHintLabel text="PowerShell" />) + expect(screen.getByText('PowerShell').tagName).not.toBe('SPAN') + }) +}) diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index b3e012d976f8..669a0eada7e4 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -3,8 +3,23 @@ import * as React from 'react' import { cn } from '@/lib/utils' -function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) { - return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} /> +function TooltipProvider({ + delayDuration = 0, + // Tips are labels, not interactive surfaces. Hoverable content + Radix's + // pointer-grace bridge is what leaves tips stuck open — especially over + // Electron `-webkit-app-region: drag` chrome where pointermove never fires + // to clear the grace area. Default off so open state tracks the trigger only. + disableHoverableContent = true, + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Provider>) { + return ( + <TooltipPrimitive.Provider + data-slot="tooltip-provider" + delayDuration={delayDuration} + disableHoverableContent={disableHoverableContent} + {...props} + /> + ) } function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) { @@ -24,18 +39,27 @@ function TooltipContent({ return ( <TooltipPrimitive.Portal> <TooltipPrimitive.Content - // Instant, no transition (the Provider's delayDuration=0 + no animate-* - // classes). bg-foreground/text-background auto-inverts per theme: white - // on near-black in light mode, black on white in dark. - className={cn( - 'z-[200] w-fit bg-foreground px-1.5 py-1 text-[11px] font-bold leading-none text-background select-none [font-family:Arial,sans-serif]', - className - )} + // Transparent, width-capped wrapper. The visible chip is the inner inline + // span so `box-decoration-break: clone` gives a marker-style background + // that hugs EACH wrapped line (bg only on the text, ragged right — no + // rectangular dead space). Instant, no transition (delayDuration=0). + // pointer-events-none: the tip must never steal hover/clicks from the + // chrome underneath (titlebar tools, adjacent tabs, etc.). + className={cn('pointer-events-none z-[200] w-fit max-w-64 select-none', className)} data-slot="tooltip-content" sideOffset={sideOffset} {...props} > - {children} + {/* bg-foreground/text-background auto-inverts per theme. leading-normal + keeps lines readable; py-1 makes the cloned line-boxes overlap just + enough to read as one continuous fill (no gaps between lines). */} + {/* [&>*]:!inline-flex: a block-level label child (e.g. `flex`) collapses + this inline decoration's geometry, so Radix measures a zero-size chip + and parks an empty rectangle in the corner (#62022). Force any direct + child inline-flex so every call site stays safe. */} + <span className="box-decoration-clone inline bg-foreground px-1.5 py-1 text-[11px] font-bold leading-normal text-background [font-family:Arial,sans-serif] [&>*]:!inline-flex"> + {children} + </span> </TooltipPrimitive.Content> </TooltipPrimitive.Portal> ) @@ -50,15 +74,15 @@ interface TipProps extends Omit<React.ComponentProps<typeof TooltipPrimitive.Con // Drop-in replacement for native `title=`: wrap any single element. Instant, // position-aware, themed. Self-contained (carries its own Provider) so it works // anywhere without a provider ancestor. Renders the child untouched when label -// is falsy. +// is falsy. Open state is trigger-hover only — never sticky, never click-blocking. function Tip({ label, children, delayDuration = 0, ...props }: TipProps) { if (!label) { return <>{children}</> } return ( - <TooltipProvider delayDuration={delayDuration}> - <Tooltip> + <TooltipProvider delayDuration={delayDuration} disableHoverableContent> + <Tooltip disableHoverableContent> <TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipContent {...props}>{label}</TooltipContent> </Tooltip> @@ -66,4 +90,25 @@ function Tip({ label, children, delayDuration = 0, ...props }: TipProps) { ) } -export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } +interface TipHintLabelProps { + text: string + hint?: string +} + +/** Tooltip label with an optional trailing hotkey hint. Uses `inline-flex` so it + * stays safe inside Tip's decoration wrapper — prefer this over a bespoke + * flex/gap span at the call site (see #62022). */ +function TipHintLabel({ text, hint }: TipHintLabelProps) { + if (!hint) { + return <>{text}</> + } + + return ( + <span className="inline-flex items-center gap-2"> + <span>{text}</span> + <span className="opacity-55">{hint}</span> + </span> + ) +} + +export { Tip, TipHintLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/apps/desktop/src/contrib/events.ts b/apps/desktop/src/contrib/events.ts new file mode 100644 index 000000000000..e76ddff7ff95 --- /dev/null +++ b/apps/desktop/src/contrib/events.ts @@ -0,0 +1,45 @@ +/** + * The plugin-facing gateway event tap. The wiring fans every inbound gateway + * event through here BEFORE its own dispatch; plugins subscribe by type (or + * `'*'`) via `host.onEvent`. Listeners are isolated — a throwing plugin can + * never break the app's event handling — and emit is zero-cost when nobody + * listens. + */ + +import type { RpcEvent } from '@/types/hermes' + +export type GatewayEventListener = (event: RpcEvent) => void + +const listeners = new Map<string, Set<GatewayEventListener>>() + +/** Subscribe to gateway events by `type` (`'*'` = everything). Returns a disposer. */ +export function onGatewayEvent(type: string, listener: GatewayEventListener): () => void { + const set = listeners.get(type) ?? new Set() + set.add(listener) + listeners.set(type, set) + + return () => { + set.delete(listener) + + if (set.size === 0) { + listeners.delete(type) + } + } +} + +/** Fan an event to subscribers (wiring-side; call before app dispatch). */ +export function emitGatewayEvent(event: RpcEvent): void { + if (listeners.size === 0) { + return + } + + for (const type of [event.type, '*']) { + for (const listener of listeners.get(type) ?? []) { + try { + listener(event) + } catch (error) { + console.error('[plugins] gateway event listener failed', error) + } + } + } +} diff --git a/apps/desktop/src/contrib/index.ts b/apps/desktop/src/contrib/index.ts new file mode 100644 index 000000000000..a5faa2f232b5 --- /dev/null +++ b/apps/desktop/src/contrib/index.ts @@ -0,0 +1,6 @@ +export { Slot } from './react/slot' +export type { SlotProps } from './react/slot' + +export { useContributions } from './react/use-contributions' +export { registry } from './registry' +export type { Contribution, ContributionSource } from './types' diff --git a/apps/desktop/src/contrib/plugin.ts b/apps/desktop/src/contrib/plugin.ts new file mode 100644 index 000000000000..c8a34d59df78 --- /dev/null +++ b/apps/desktop/src/contrib/plugin.ts @@ -0,0 +1,111 @@ +/** + * The plugin authoring contract. A plugin is a file that default-exports a + * `HermesPlugin`; it never touches the registry directly — it receives a + * scoped `PluginContext` whose `register` auto-tags provenance + * (`source: 'plugin:<id>'`) and namespaces the contribution id + * (`<id>:<localId>`), so authors write plain contributions and collisions + * between plugins are impossible. + * + * Bundled plugins live in `src/plugins/<name>/plugin.tsx` and are discovered + * by `discoverBundledPlugins()` (contrib/plugins.ts) — no import, no registry + * edit. Runtime-fetched third-party plugins will drive the SAME contract + * through the plugin host loader (next phase); this is that seam. + */ + +import { pluginRest, type PluginRestOptions, pluginSocket } from '@/hermes' +import { readKey, writeKey } from '@/lib/storage' + +import { registry } from './registry' +import type { Contribution } from './types' + +export type { PluginRestOptions } from '@/hermes' + +/** A contribution as a plugin author writes it — provenance + id scoping are + * the host's job, so those fields are off-limits here. */ +export type PluginContribution = Omit<Contribution, 'source' | 'id'> & { id: string } + +/** Namespaced JSON persistence (the VS Code `globalState` analog). Keys live + * under `hermes.plugin.<id>.` — plugins can't read or clobber each other. */ +export interface PluginStorage { + get<T>(key: string, fallback: T): T + set(key: string, value: unknown): void + remove(key: string): void +} + +export interface PluginContext { + /** The resolved plugin source tag, e.g. `'plugin:cost-meter'`. */ + readonly source: string + /** Register one contribution (id namespaced, source stamped). */ + register: (c: PluginContribution) => () => void + /** Register several at once; the returned disposer removes all of them. */ + registerMany: (cs: PluginContribution[]) => () => void + /** REST to this plugin's own backend namespace (`/api/plugins/<id>`); `path` + * is relative ('/board'). The sanctioned door for a plugin that ships a + * `plugin_api.py` — profile-aware, namespace-scoped by construction. Use + * `host.request` for gateway JSON-RPC. */ + rest: <T>(path: string, opts?: PluginRestOptions) => Promise<T> + /** Live twin of `rest`: a WebSocket to this plugin's own namespace + * ('/events'), JSON frames to `onMessage`, auto-reconnect, disposer + * returned. Resolves to a no-op on OAuth remotes — treat it as an + * accelerator over your polling, never a replacement. */ + socket: (path: string, onMessage: (data: unknown) => void) => () => void + /** Plugin-scoped persistence. */ + storage: PluginStorage +} + +export interface HermesPlugin { + /** Stable slug — becomes the `plugin:<id>` source and the id namespace. */ + id: string + /** Human name for settings / about UI. */ + name?: string + /** Registers on load when the user hasn't chosen (default true). Set false + * for opt-in plugins: they inventory in Settings ▸ Plugins, off until the + * user flips the switch. */ + defaultEnabled?: boolean + /** Called once at load; wire contributions through `ctx`. */ + register: (ctx: PluginContext) => void +} + +function createPluginStorage(pluginId: string): PluginStorage { + const scoped = (key: string) => `hermes.plugin.${pluginId}.${key}` + + return { + get(key, fallback) { + const raw = readKey(scoped(key)) + + if (raw === null) { + return fallback + } + + try { + return JSON.parse(raw) + } catch { + return fallback + } + }, + set: (key, value) => writeKey(scoped(key), JSON.stringify(value)), + remove: key => writeKey(scoped(key), null) + } +} + +/** Build the scoped context handed to a plugin's `register`. `onDispose` + * receives every registration's disposer (the loader's unload/reload hook). */ +export function createPluginContext(pluginId: string, onDispose?: (dispose: () => void) => void): PluginContext { + const source = `plugin:${pluginId}` + const scope = (c: PluginContribution): Contribution => ({ ...c, id: `${pluginId}:${c.id}`, source }) + + const track = (dispose: () => void) => { + onDispose?.(dispose) + + return dispose + } + + return { + source, + register: c => track(registry.register(scope(c))), + registerMany: cs => track(registry.registerMany(cs.map(scope))), + rest: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(pluginId, path, opts), + socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)), + storage: createPluginStorage(pluginId) + } +} diff --git a/apps/desktop/src/contrib/plugins-store.ts b/apps/desktop/src/contrib/plugins-store.ts new file mode 100644 index 000000000000..9e90a4cc0be6 --- /dev/null +++ b/apps/desktop/src/contrib/plugins-store.ts @@ -0,0 +1,124 @@ +/** + * PLUGIN INVENTORY — the reactive record of every desktop plugin the app + * knows about (bundled `src/plugins/*`, the in-repo runtime example, the + * `<hermes home>/desktop-plugins/*` disk door — incl. agent-written ones), + * plus the persisted DISABLED set. The settings "Plugins" page renders this; + * the loaders publish into it and consult the disabled set before + * registering. Enable/disable is live: each record carries the loader's own + * activate/deactivate handles, so toggling never needs an app reload. + */ + +import { atom } from 'nanostores' + +export type PluginKind = 'bundled' | 'disk' | 'runtime' +export type PluginStatus = 'disabled' | 'error' | 'loaded' + +export interface PluginRecord { + id: string + name: string + kind: PluginKind + status: PluginStatus + /** Load/registration failure message (status 'error'). */ + error?: string + /** Absolute plugin.js path (disk plugins) — powers "Reveal in Finder". */ + file?: string +} + +// Explicit user enable/disable choices, id -> boolean. ABSENCE means "no +// choice" — the plugin falls back to its own `defaultEnabled`. This is what +// lets an opt-in plugin ship off-by-default: absence ≠ enabled anymore. +const DECISIONS_KEY = 'hermes.desktop.pluginDecisions.v2' +const LEGACY_DISABLED_KEY = 'hermes.desktop.disabledPlugins.v1' + +function loadDecisions(): Record<string, boolean> { + try { + const raw = window.localStorage.getItem(DECISIONS_KEY) + + if (raw) { + return JSON.parse(raw) as Record<string, boolean> + } + + // Migrate the v1 disabled-set: each disabled id becomes an explicit `false`. + const legacy = window.localStorage.getItem(LEGACY_DISABLED_KEY) + + if (legacy) { + return Object.fromEntries((JSON.parse(legacy) as string[]).map(id => [id, false])) + } + } catch { + // Nonfatal — fall through to no choices. + } + + return {} +} + +export const $pluginDecisions = atom<Record<string, boolean>>(loadDecisions()) + +/** Whether a plugin should register: the user's explicit choice if any, else + * the plugin's own default (true for ordinary plugins, false for opt-in). */ +export function pluginActive(id: string, defaultEnabled = true): boolean { + const decisions = $pluginDecisions.get() + + return id in decisions ? decisions[id] : defaultEnabled +} + +function saveDecisions(next: Record<string, boolean>) { + $pluginDecisions.set(next) + + try { + window.localStorage.setItem(DECISIONS_KEY, JSON.stringify(next)) + } catch { + // Nonfatal. + } +} + +export const $pluginRecords = atom<Record<string, PluginRecord>>({}) + +/** Loader-owned lifecycle controls for a plugin (activate/deactivate). */ +interface PluginHandle { + activate: () => Promise<void> | void + deactivate: () => void +} + +/** Loader-owned lifecycle handles, keyed by plugin id. */ +const handles = new Map<string, PluginHandle>() + +/** Publish/refresh a plugin's record + its activate/deactivate handles. */ +export function publishPlugin(record: PluginRecord, handle?: PluginHandle): void { + $pluginRecords.set({ ...$pluginRecords.get(), [record.id]: record }) + + if (handle) { + handles.set(record.id, handle) + } +} + +export function patchPlugin(id: string, patch: Partial<PluginRecord>): void { + const current = $pluginRecords.get()[id] + + if (current) { + $pluginRecords.set({ ...$pluginRecords.get(), [id]: { ...current, ...patch } }) + } +} + +export function dropPlugin(id: string): void { + const { [id]: _dropped, ...rest } = $pluginRecords.get() + $pluginRecords.set(rest) + handles.delete(id) +} + +/** Live toggle: deactivate + remember, or forget + reactivate. */ +export async function setPluginEnabled(id: string, enabled: boolean): Promise<void> { + saveDecisions({ ...$pluginDecisions.get(), [id]: enabled }) + + const handle = handles.get(id) + + if (!handle) { + return + } + + if (enabled) { + await handle.activate() + } else { + handle.deactivate() + patchPlugin(id, { status: 'disabled' }) + } +} diff --git a/apps/desktop/src/contrib/plugins.ts b/apps/desktop/src/contrib/plugins.ts new file mode 100644 index 000000000000..d1cab8a32fba --- /dev/null +++ b/apps/desktop/src/contrib/plugins.ts @@ -0,0 +1,74 @@ +/** + * Plugin discovery — both delivery modes: + * + * - BUNDLED: every `src/plugins/<name>/plugin.{ts,tsx}` default-exporting a + * `HermesPlugin` registers automatically (vite glob — drop a folder in). + * None ship in-tree today; reference/demo plugins live in the companion + * `hermes-example-plugins` repo. + * - RUNTIME: the on-disk door (`<hermes home>/desktop-plugins/<name>/plugin.js`) + * — the agent's/user's door, watched + hot-reloaded by the runtime loader. + */ + +import { createPluginContext, type HermesPlugin } from './plugin' +import { pluginActive, publishPlugin } from './plugins-store' +import { watchRuntimePlugins } from './runtime-loader' + +const modules = import.meta.glob<{ default: HermesPlugin }>('../plugins/*/plugin.{ts,tsx}', { eager: true }) + +// One-shot init guard. Contributions themselves register by id (re-registering +// is idempotent), but the disk-door watcher setup below (watchRuntimePlugins) +// must NOT run twice — so discovery is guarded to a single pass, not re-run on +// HMR. +let loaded = false + +export function discoverBundledPlugins(): void { + if (loaded) { + return + } + + loaded = true + + for (const [path, mod] of Object.entries(modules)) { + const plugin = mod.default + + if (!plugin?.id || typeof plugin.register !== 'function') { + console.warn(`[plugins] ${path} has no valid default HermesPlugin export — skipped`) + + continue + } + + // Same inventory + live-toggle contract as runtime plugins: each bundled + // plugin publishes a record with activate/deactivate handles, and a + // persisted disable survives boots by skipping registration here. + const record = { id: plugin.id, name: plugin.name ?? plugin.id, kind: 'bundled' as const } + let disposers: (() => void)[] = [] + + const activate = () => { + disposers.forEach(dispose => dispose()) + disposers = [] + + try { + plugin.register(createPluginContext(plugin.id, dispose => disposers.push(dispose))) + publishPlugin({ ...record, status: 'loaded' }) + } catch (error) { + console.error(`[plugins] ${plugin.id} failed to register`, error) + publishPlugin({ ...record, status: 'error', error: error instanceof Error ? error.message : String(error) }) + } + } + + const deactivate = () => { + disposers.forEach(dispose => dispose()) + disposers = [] + } + + publishPlugin({ ...record, status: 'disabled' }, { activate, deactivate }) + + if (pluginActive(plugin.id, plugin.defaultEnabled ?? true)) { + activate() + } + } + + // The SELF-MAINTAINING disk door (fs-watched hot reloads, slow folder + // reconciliation) — the runtime loader pipeline's real, shipping consumer. + watchRuntimePlugins() +} diff --git a/apps/desktop/src/contrib/react/boundary.tsx b/apps/desktop/src/contrib/react/boundary.tsx new file mode 100644 index 000000000000..570ba6c8f4c3 --- /dev/null +++ b/apps/desktop/src/contrib/react/boundary.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from 'react' + +import { ErrorBoundary } from '@/components/error-boundary' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { ErrorState } from '@/components/ui/error-state' +import { Tip } from '@/components/ui/tooltip' + +interface ContribBoundaryProps { + children: ReactNode + /** Contribution key, shown in the fallback + console tag. */ + id: string + /** `chip` = inline bar item (tiny fallback); `pane` = zone body. */ + variant?: 'chip' | 'pane' +} + +/** + * The blast wall between a contribution's `render()` and the app. Plugin + * code throwing during render (bad import, undefined component, logic bug) + * degrades to a small inline error in ITS slot — the surrounding bar/zone, + * other plugins, and the app keep working. Every surface that mounts + * contribution renders wraps them in this. + * + * The pane fallback uses the app's canonical `ErrorState` (same icon/title/body + * as the React boundary and dialog errors) so a crashed contribution reads like + * every other failure, not a raw stack dump. + */ +export function ContribBoundary({ children, id, variant = 'pane' }: ContribBoundaryProps) { + return ( + <ErrorBoundary + fallback={({ error, reset }) => + variant === 'chip' ? ( + <Tip label={`${id}: ${error.message}`}> + <button + className="inline-flex items-center gap-1 rounded px-1.5 text-[0.6875rem] text-destructive transition-colors hover:bg-(--chrome-action-hover)" + onClick={reset} + type="button" + > + <Codicon name="warning" size="0.7rem" /> + {id} + </button> + </Tip> + ) : ( + <div className="grid h-full place-items-center p-6"> + <ErrorState description={error.message} title={`“${id}” failed to render`}> + <Button className="justify-self-center" onClick={reset} size="sm" variant="outline"> + <Codicon name="refresh" size="0.8rem" /> + Retry + </Button> + </ErrorState> + </div> + ) + } + label={`contrib:${id}`} + > + {children} + </ErrorBoundary> + ) +} diff --git a/apps/desktop/src/contrib/react/contribute.tsx b/apps/desktop/src/contrib/react/contribute.tsx new file mode 100644 index 000000000000..4526e3cdbfed --- /dev/null +++ b/apps/desktop/src/contrib/react/contribute.tsx @@ -0,0 +1,51 @@ +/** + * Mount-scoped contribution — the "reverse portal" companion to `Slot`. + * + * `ctx.register` is for PERMANENT contributions (registered at plugin load, + * alive for the plugin's lifetime). `<Contribute>` is for chrome that belongs + * to a mounted surface: while the owning component is mounted, `children` + * render inside the target area's Slot; on unmount the contribution disposes + * itself. No route sniffing, no `when()` polling — React's lifecycle IS the + * visibility contract (a page's titlebar control leaves with the page). + * + * Children stay LIVE: they're projected through a nanostore the registered + * render subscribes to, so caller state flows into the slot on every render + * without re-registering (which would churn every other slot). + */ + +import { useStore } from '@nanostores/react' +import { atom, type WritableAtom } from 'nanostores' +import { type ReactNode, useEffect, useRef } from 'react' + +import { registry } from '../registry' + +function ProjectedNode({ $node }: { $node: WritableAtom<ReactNode> }) { + return <>{useStore($node)}</> +} + +export interface ContributeProps { + /** Target area id (e.g. `titleBar.center`). */ + area: string + /** Stable contribution id — namespace it (`kanban:board-switcher`). */ + id: string + order?: number + children: ReactNode +} + +export function Contribute({ area, children, id, order }: ContributeProps) { + const $node = useRef<WritableAtom<ReactNode>>(null!) + + $node.current ??= atom<ReactNode>(null) + + // Push the latest children into the projection after every render. + useEffect(() => { + $node.current.set(children) + }) + + useEffect( + () => registry.register({ area, id, order, render: () => <ProjectedNode $node={$node.current} /> }), + [area, id, order] + ) + + return null +} diff --git a/apps/desktop/src/contrib/react/slot.tsx b/apps/desktop/src/contrib/react/slot.tsx new file mode 100644 index 000000000000..802cc0bf817b --- /dev/null +++ b/apps/desktop/src/contrib/react/slot.tsx @@ -0,0 +1,26 @@ +import { ContribBoundary } from './boundary' +import { useContributions } from './use-contributions' + +export interface SlotProps { + /** Area id whose contributions render inline, in order. */ + area: string +} + +/** Renders a bar area: ordered inline items `[...core, ...plugin]`. */ +export function Slot({ area }: SlotProps) { + const items = useContributions(area) + + if (items.length === 0) { + return null + } + + return ( + <> + {items.map(c => ( + <ContribBoundary id={c.id} key={`${c.source ?? 'core'}:${c.id}`} variant="chip"> + {c.render?.()} + </ContribBoundary> + ))} + </> + ) +} diff --git a/apps/desktop/src/contrib/react/use-contributions.ts b/apps/desktop/src/contrib/react/use-contributions.ts new file mode 100644 index 000000000000..a7486ec1cb8f --- /dev/null +++ b/apps/desktop/src/contrib/react/use-contributions.ts @@ -0,0 +1,14 @@ +import { useCallback, useSyncExternalStore } from 'react' + +import { registry } from '../registry' +import type { Contribution } from '../types' + +/** Subscribe to the resolved contributions for an area. The subscription is + * scoped to `area`, so a slot re-renders only when ITS area mutates — a + * statusbar registration never re-renders a titlebar (or panes) slot. */ +export function useContributions(area: string): readonly Contribution[] { + const subscribe = useCallback((onChange: () => void) => registry.subscribeArea(area, onChange), [area]) + const getSnapshot = useCallback(() => registry.getArea(area), [area]) + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +} diff --git a/apps/desktop/src/contrib/registry.ts b/apps/desktop/src/contrib/registry.ts new file mode 100644 index 000000000000..1a8f1f2e1c6e --- /dev/null +++ b/apps/desktop/src/contrib/registry.ts @@ -0,0 +1,162 @@ +import { atom } from 'nanostores' + +import type { Contribution } from './types' + +/** Bumped on every registry mutation — the reactive hook for non-React + * consumers (nanostores `computed`s, memo deps) that read `registry.getArea` + * imperatively. React trees should keep using `useContributions`. */ +export const $registryVersion = atom(0) + +type Listener = () => void + +const EMPTY: readonly Contribution[] = Object.freeze([]) + +/** + * The one registry every area reads from. Keyed by namespaced area id so the + * same primitive resolves at any depth of the recursive Area scene graph + * (`statusBar.right`, `rightColumn.panel`, `capabilities.detail.actions`, ...). + * + * Snapshots are cached per area and only invalidated on mutation so the value + * is referentially stable for `useSyncExternalStore` (no render loops). + * + * Invalidation is AREA-SCOPED: mutating one area only clears that area's + * snapshot and only notifies subscribers of that area, so registering a + * statusbar item can never re-render a titlebar slot (or any other unrelated + * area). `registerMany`/`removeMany` collapse a batch into one notification + * per touched area. A global listener channel (`subscribe`) still fires on + * every mutation for engines that react to any change (pane adoption). + * + * Note: cache invalidation is mutation-driven, so a purely dynamic `when()` + * that flips without a register/remove won't re-resolve on its own -- fine for + * the current surfaces (no dynamic `when` is in use); revisit if we need + * reactive `when`. + */ +class ContributionRegistry { + private byArea = new Map<string, Contribution[]>() + private snapshot = new Map<string, readonly Contribution[]>() + private areaListeners = new Map<string, Set<Listener>>() + private globalListeners = new Set<Listener>() + + /** Register one contribution. Returns a disposer that removes it. */ + register = (c: Contribution): (() => void) => this.registerMany([c]) + + /** Register several at once. Returns a disposer that removes all of them. + * A batch touches each affected area exactly once — no per-item churn. */ + registerMany = (cs: Contribution[]): (() => void) => { + cs.forEach(c => this.put(c)) + this.invalidate(cs.map(c => c.area)) + + return () => this.removeMany(cs.map(c => ({ area: c.area, id: c.id }))) + } + + /** Resolved, sorted, filtered entries for an area. Stable ref until mutated. */ + getArea = (area: string): readonly Contribution[] => { + const cached = this.snapshot.get(area) + + if (cached) { + return cached + } + + const raw = this.byArea.get(area) + + const resolved: readonly Contribution[] = + !raw || raw.length === 0 + ? EMPTY + : raw + .filter(c => c.enabled !== false && (c.when ? c.when() : true)) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + + this.snapshot.set(area, resolved) + + return resolved + } + + /** Subscribe to ANY registry mutation (engines that react to every change, + * e.g. pane adoption). React trees should prefer `subscribeArea`. */ + subscribe = (fn: Listener): (() => void) => { + this.globalListeners.add(fn) + + return () => { + this.globalListeners.delete(fn) + } + } + + /** Subscribe to mutations of ONE area only — the leaf-level channel behind + * `useContributions`, so a slot re-renders solely for its own area. */ + subscribeArea = (area: string, fn: Listener): (() => void) => { + const set = this.areaListeners.get(area) ?? new Set<Listener>() + set.add(fn) + this.areaListeners.set(area, set) + + return () => { + set.delete(fn) + + if (set.size === 0) { + this.areaListeners.delete(area) + } + } + } + + private removeMany(entries: { area: string; id: string }[]) { + const changed: string[] = [] + + for (const { area, id } of entries) { + if (this.take(area, id)) { + changed.push(area) + } + } + + if (changed.length) { + this.invalidate(changed) + } + } + + /** Insert/replace an entry without notifying (batchable). */ + private put(c: Contribution) { + const list = this.byArea.get(c.area) ?? [] + this.byArea.set(c.area, [...list.filter(e => e.id !== c.id), c]) + } + + /** Remove an entry without notifying (batchable). Returns whether it existed. */ + private take(area: string, id: string): boolean { + const list = this.byArea.get(area) + + if (!list) { + return false + } + + const next = list.filter(e => e.id !== id) + + if (next.length === list.length) { + return false + } + + if (next.length) { + this.byArea.set(area, next) + } else { + this.byArea.delete(area) + } + + return true + } + + private invalidate(areas: readonly string[]) { + const unique = new Set(areas) + + for (const area of unique) { + this.snapshot.delete(area) + + const listeners = this.areaListeners.get(area) + + if (listeners) { + listeners.forEach(l => l()) + } + } + + // Global listeners fire once per batch, regardless of how many areas moved. + this.globalListeners.forEach(l => l()) + $registryVersion.set($registryVersion.get() + 1) + } +} + +export const registry = new ContributionRegistry() diff --git a/apps/desktop/src/contrib/runtime-loader.ts b/apps/desktop/src/contrib/runtime-loader.ts new file mode 100644 index 000000000000..bf1fe2ca9dc4 --- /dev/null +++ b/apps/desktop/src/contrib/runtime-loader.ts @@ -0,0 +1,332 @@ +/** + * Runtime plugin loader — plugins as CODE, not registry edits, loaded after + * build time. The pipeline every non-bundled plugin takes: + * + * source (plain ESM js) -> [integrity check] -> bare-specifier rewrite + * (`@hermes/plugin-sdk` / `react*` -> live shim blobs, see sdk/runtime.ts) + * -> blob `import()` -> validate default HermesPlugin -> register(ctx) + * + * Loading the same plugin id again disposes the previous registrations first + * (agent rewrites a plugin file -> clean reload). Failures toast + log; a + * broken plugin can never take the app down. + * + * Sources today: the in-repo runtime example (`?raw`, proves the pipeline) + * and `<hermes home>/desktop-plugins/<name>/plugin.js` on disk — the door the + * agent writes through. + * + * SECURITY — this is NOT a capability boundary. A loaded plugin is evaluated + * as ESM in the renderer realm with FULL app authority: the React singleton, + * the whole SDK (`host.request` gateway RPC, `ctx.rest`, storage, `navigate`). + * The isolation here is *error* isolation only (ContribBoundary, isolated + * listeners) — a plugin can't crash the app, but it can do anything the app + * can. That's acceptable for local sources (disk files can already run code), + * and `integrity` only proves the bytes match a hash — it does NOT sandbox. + * A remote source (https + allowlist) must NOT reuse this pipeline as-is: + * it needs a real boundary (iframe/worker + CSP + capability gating) before + * it can land. The `{ integrity }` option is the transport seam, not the + * trust seam. + */ + +import { getStatus } from '@/hermes' +import { installPluginSdk, sdkImportMap } from '@/sdk/runtime' +import { notifyError } from '@/store/notifications' + +import { createPluginContext, type HermesPlugin } from './plugin' +import { dropPlugin, pluginActive, type PluginKind, publishPlugin } from './plugins-store' + +interface LoadOptions { + /** Absolute plugin.js path (disk plugins) — recorded for reveal/inventory. */ + file?: string + /** `sha256-<base64>` — verified against the source before evaluation. */ + integrity?: string + /** Inventory bucket; the disk door is the default runtime source. */ + kind?: PluginKind +} + +/** Live runtime plugins: id -> disposers (unload/reload support). */ +const loaded = new Map<string, (() => void)[]>() + +// Matches the specifier of a static `from '…'`, a side-effect `import '…'`, or +// a dynamic `import('…')` — anchored to import/export syntax so a bare string +// literal or comment (e.g. `notify('react')`) is never touched. +const importSpecifierRe = () => /(from\s*|import\s*\(\s*|import\s+)(['"])([^'"]+)\2/g + +/** Rewrite ONLY mapped import specifiers (@hermes/plugin-sdk, react*) to their + * live shim blob URLs — never occurrences inside strings/comments. */ +function rewriteSpecifiers(source: string): string { + const map = sdkImportMap() + + return source.replace(importSpecifierRe(), (whole, pre, quote, spec) => + map[spec] ? `${pre}${quote}${map[spec]}${quote}` : whole + ) +} + +/** Bare import specifiers the loader can't resolve (not relative/URL, not in + * the SDK map). Surfaced up-front so they don't fail as a cryptic native + * "Failed to resolve module specifier" from the blob import. */ +function unsupportedImports(source: string): string[] { + const map = sdkImportMap() + const bare = new Set<string>() + + for (const m of source.matchAll(importSpecifierRe())) { + const spec = m[3] + + // Skip relative/absolute (./ ../ /) and any URL scheme (blob: http(s):). + if (spec && !/^[./]/.test(spec) && !/^[a-z][a-z0-9+.-]*:/i.test(spec) && !map[spec]) { + bare.add(spec) + } + } + + return [...bare] +} + +async function verifyIntegrity(source: string, integrity: string): Promise<boolean> { + const [algo, expected] = integrity.split('-', 2) + + if (algo !== 'sha256' || !expected) { + return false + } + + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source)) + // Standard SRI base64 (`sha256-<base64>`) — a base64url-encoded hash won't match. + const actual = btoa(String.fromCharCode(...new Uint8Array(digest))) + + return actual === expected +} + +export function unloadRuntimePlugin(id: string): void { + loaded.get(id)?.forEach(dispose => dispose()) + loaded.delete(id) +} + +/** Evaluate + register one runtime plugin. Returns its id, or null on failure. */ +export async function loadRuntimePlugin(source: string, origin: string, options: LoadOptions = {}): Promise<null | string> { + installPluginSdk() + + try { + if (options.integrity && !(await verifyIntegrity(source, options.integrity))) { + throw new Error(`integrity check failed for ${origin}`) + } + + const unsupported = unsupportedImports(source) + + if (unsupported.length > 0) { + throw new Error( + `unsupported import${unsupported.length > 1 ? 's' : ''}: ${unsupported.join(', ')} — ` + + `runtime plugins may only import @hermes/plugin-sdk and react` + ) + } + + const url = URL.createObjectURL(new Blob([rewriteSpecifiers(source)], { type: 'text/javascript' })) + + let mod: { default?: HermesPlugin } + + try { + mod = await import(/* @vite-ignore */ url) + } finally { + URL.revokeObjectURL(url) + } + + const plugin = mod.default + + if (!plugin?.id || typeof plugin.register !== 'function') { + throw new Error(`${origin} has no valid default HermesPlugin export`) + } + + const record = { + id: plugin.id, + name: plugin.name ?? plugin.id, + kind: options.kind ?? 'disk', + file: options.file + } + + const activate = () => { + // Reload = dispose the previous incarnation, then register fresh. + unloadRuntimePlugin(plugin.id) + const disposers: (() => void)[] = [] + plugin.register(createPluginContext(plugin.id, dispose => disposers.push(dispose))) + loaded.set(plugin.id, disposers) + publishPlugin({ ...record, status: 'loaded' }) + } + + publishPlugin({ ...record, status: 'disabled' }, { activate, deactivate: () => unloadRuntimePlugin(plugin.id) }) + + // A disabled plugin still inventories (settings shows it, toggle + // reactivates via the handle above) — it just never registers. + if (pluginActive(plugin.id, plugin.defaultEnabled ?? true)) { + activate() + } + + return plugin.id + } catch (error) { + console.error(`[plugins] runtime load failed (${origin})`, error) + notifyError(error, `Plugin "${origin}" failed to load`) + publishPlugin({ + id: origin, + name: origin, + kind: options.kind ?? 'disk', + file: options.file, + status: 'error', + error: error instanceof Error ? error.message : String(error) + }) + + return null + } +} + +// --------------------------------------------------------------------------- +// The on-disk plugin door: `<hermes home>/desktop-plugins/<name>/plugin.js` +// (agent- or user-written). SELF-MAINTAINING — no reload ceremony: +// - each plugin.js is fs-watched (the preview watcher IPC, debounced in +// main): saving the file hot-reloads the plugin in place; +// - a slow visible-tab poll of the directory picks up new folders (load + +// watch) and removed ones (unload + unwatch). +// Panes land via placement adoption and STAY where the user drags them — +// the tree treats not-yet-loaded pane ids as hidden, so boot and reload are +// collapse -> appear, never a placeholder flash. +// --------------------------------------------------------------------------- + +const DISK_POLL_MS = 5_000 + +interface DiskPlugin { + file: string + /** Loaded plugin id (null while broken — kept so a fixing save reloads). */ + id: null | string + watchId: null | string +} + +const disk = new Map<string, DiskPlugin>() +let watching = false +let scanning = false + +async function loadDiskPlugin(name: string, file: string): Promise<void> { + const desktop = window.hermesDesktop! + const entry = disk.get(name) + const prevId = entry?.id + + try { + const { text } = await desktop.readFileText(file) + const id = await loadRuntimePlugin(text, name, { file }) + + // A hot-edit that changes `plugin.id`: loadRuntimePlugin only disposes the + // NEW id, so unload the previous incarnation here or its contributions + + // inventory row orphan. + if (id && prevId && prevId !== id) { + unloadRuntimePlugin(prevId) + dropPlugin(prevId) + } + + if (entry) { + entry.id = id ?? entry.id + } + + // A fixing save under a different plugin id — drop the folder-named + // error record so the inventory shows one row, not a ghost. + if (id && id !== name) { + dropPlugin(name) + } + } catch { + // File vanished mid-read — the next scan reconciles. + } +} + +async function scanDiskPlugins(): Promise<void> { + const desktop = window.hermesDesktop + + // Re-entrancy guard: the 5s poll must not overlap a slow in-flight scan + // (reads/loads can exceed the interval). + if (!desktop || scanning) { + return + } + + scanning = true + + try { + const { hermes_home } = await getStatus() + const { entries } = await desktop.readDir(`${hermes_home}/desktop-plugins`) + const seen = new Set<string>() + + for (const dir of entries.filter(e => e.isDirectory)) { + seen.add(dir.name) + + if (disk.has(dir.name)) { + continue + } + + const file = `${dir.path}/plugin.js` + + try { + await desktop.readFileText(file) + } catch { + continue // No plugin.js (yet) — not a plugin folder. + } + + const record: DiskPlugin = { file, id: null, watchId: null } + disk.set(dir.name, record) + await loadDiskPlugin(dir.name, file) + + try { + record.watchId = (await desktop.watchPreviewFile(file)).id + } catch { + // Unwatchable — the poll still reconciles new folders; edits need a + // manual "Reload desktop plugins". + } + } + + // Folder deleted -> plugin gone, cleanly (inventory row included). + for (const [name, record] of disk) { + if (seen.has(name)) { + continue + } + + if (record.id) { + unloadRuntimePlugin(record.id) + dropPlugin(record.id) + } + + dropPlugin(name) + + if (record.watchId) { + void desktop.stopPreviewFileWatch(record.watchId) + } + + disk.delete(name) + } + } catch { + // No desktop-plugins dir (or no gateway yet) — nothing to reconcile. + } finally { + scanning = false + } +} + +/** Manual rescan (the ⌘K "Reload desktop plugins" fallback). */ +export const discoverRuntimePlugins = scanDiskPlugins + +/** Start the self-maintaining disk door: initial scan, per-file hot reload, + * slow folder reconciliation while the window is visible. Idempotent. */ +export function watchRuntimePlugins(): void { + const desktop = window.hermesDesktop + + if (watching || !desktop) { + return + } + + watching = true + + desktop.onPreviewFileChanged(({ id }) => { + for (const [name, record] of disk) { + if (record.watchId === id) { + void loadDiskPlugin(name, record.file) + + return + } + } + }) + + void scanDiskPlugins() + window.setInterval(() => { + if (document.visibilityState === 'visible') { + void scanDiskPlugins() + } + }, DISK_POLL_MS) +} diff --git a/apps/desktop/src/contrib/types.ts b/apps/desktop/src/contrib/types.ts new file mode 100644 index 000000000000..c46ee3e9929e --- /dev/null +++ b/apps/desktop/src/contrib/types.ts @@ -0,0 +1,43 @@ +import type { ReactNode } from 'react' + +/** + * Where a contribution came from. `'core'` is the app's own default UI; + * anything else is a plugin/extension id (e.g. `'plugin:kanban'`). This is the + * provenance tag that drives precedence and, later, the trust/capability gate + * (WoW-style taint: plugin-sourced contributions can be blocked from privileged + * actions unless granted). + */ +export type ContributionSource = 'core' | (string & {}) + +/** + * The single, uniform primitive every surface consumes. A bar renders these as + * inline items via `<Slot>`; a dock renders them as stacked/tabbed panes via + * `<PaneHost>`. Same shape either way -- the host decides how to present them. + */ +export interface Contribution { + /** Stable id, unique within its area. Re-registering the same id replaces it. */ + id: string + /** Namespaced area id this contribution targets, e.g. `'secondarySidebar'`. */ + area: string + /** Provenance; defaults to `'core'` when omitted. */ + source?: ContributionSource + /** Human label (pane tab / header). Optional for bar items. */ + title?: string + /** Ascending sort key within the area; ties keep insertion order. */ + order?: number + /** Dynamic visibility predicate. Omit for always-on. + * NOTE: evaluated when the area's snapshot is (re)built — i.e. on a + * register/remove in that area, NOT reactively. A `when()` that flips on + * external state won't re-resolve on its own; trigger a registry mutation + * (or don't rely on it flipping without one). */ + when?: () => boolean + /** Soft disable without unregistering. `false` hides it. */ + enabled?: boolean + /** Renders the contribution's content (UI contributions). */ + render?: () => ReactNode + /** + * Declarative payload for data contributions (Family B): layout presets, + * themes, commands — anything consumed by an engine rather than rendered. + */ + data?: unknown +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index f9c5e34f5417..a37091ceeb49 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -55,6 +55,15 @@ declare global { probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult> oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult> oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult> + // Hermes Cloud: one portal login powers discovery + silent per-agent + // sign-in (cloud-auto-discovery Phase 3). + cloud: { + status: () => Promise<DesktopCloudStatus> + login: () => Promise<DesktopCloudStatus & { ok: boolean }> + logout: () => Promise<DesktopCloudStatus & { ok: boolean }> + discover: (org?: string) => Promise<DesktopCloudDiscoverResult> + agentSignIn: (dashboardUrl: string) => Promise<DesktopCloudAgentSignInResult> + } profile: { get: () => Promise<DesktopActiveProfile> // Persists the desktop's profile choice and relaunches the local @@ -100,6 +109,8 @@ declare global { gitRoot?: (path: string) => Promise<string | null> // Reveal a path in the OS file manager (Finder / Explorer). revealPath?: (path: string) => Promise<boolean> + // Open a DIRECTORY (created if missing) in the OS file manager. + openDir?: (path: string) => Promise<{ ok: boolean; error?: string }> // Rename a file/folder in place (new base name, same parent dir). renamePath?: (path: string, newName: string) => Promise<{ path: string }> // Write a small UTF-8 text file (hardened path, parent must exist). @@ -121,6 +132,10 @@ declare global { branchSwitch: (repoPath: string, branch: string) => Promise<{ branch: string }> // Local branches for the "convert a branch into a worktree" picker. branchList: (repoPath: string) => Promise<HermesGitBranch[]> + // Local + remote-tracking branches for the "base branch" picker in the + // new-worktree dialog. The remote default (origin/HEAD) is flagged so + // the UI can preselect it. + baseBranchList: (repoPath: string) => Promise<HermesGitBaseBranch[]> // Compact working-tree status for the composer coding rail. Null on a // non-repo / remote backend (where the Electron probe can't run). repoStatus: (repoPath: string) => Promise<HermesRepoStatus | null> @@ -154,6 +169,10 @@ declare global { scanRepos: (roots: string[], options?: { maxDepth?: number }) => Promise<{ root: string; label: string }[]> } terminal: { + /** Best-effort current working directory of the live PTY child (POSIX + * only; null on Windows or when unavailable). Used to reopen a tab + * where the user last `cd`'d. */ + cwd: (id: string) => Promise<string | null> dispose: (id: string) => Promise<boolean> onData: (id: string, callback: (payload: string) => void) => () => void onExit: (id: string, callback: (payload: HermesTerminalExit) => void) => () => void @@ -172,6 +191,9 @@ declare global { onNotificationAction?: (callback: (payload: { actionId: string; sessionId?: string }) => void) => () => void onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void onBackendExit: (callback: (payload: BackendExit) => void) => () => void + // Soft gateway-mode apply: primary backend was torn down without a window + // reload. Wipe session lists (skeletons) and re-dial. + onConnectionApplied?: (callback: () => void) => () => void onPowerResume?: (callback: () => void) => () => void onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void getBootstrapState: () => Promise<DesktopBootstrapState> @@ -359,6 +381,9 @@ export interface DesktopUpdateProgress { export interface HermesConnection { baseUrl: string isFullscreen: boolean + // The live, RESOLVED connection mode. Only ever 'local' or 'remote' — a + // 'cloud' saved-config entry resolves to a 'remote' connection under the hood + // (cloud-auto-discovery Q3/Q6), so this never carries 'cloud'. mode?: 'local' | 'remote' authMode?: 'oauth' | 'token' nativeOverlayWidth: number @@ -391,7 +416,12 @@ export interface DesktopActiveProfile { export interface DesktopConnectionConfig { envOverride: boolean - mode: 'local' | 'remote' + // The saved connection mode. 'cloud' is a Hermes Cloud connection: it carries + // a remote-shaped block (remoteUrl = the selected agent's dashboardUrl, + // remoteAuthMode 'oauth') but is remembered as cloud so settings reopens into + // the cloud picker. Resolution treats cloud exactly as remote + // (cloud-auto-discovery Q3/Q6). + mode: 'local' | 'remote' | 'cloud' // The profile this config describes, or null for the global/default // connection. Per-profile entries let a profile point at its own backend. profile: null | string @@ -400,16 +430,23 @@ export interface DesktopConnectionConfig { remoteTokenPreview: string | null remoteTokenSet: boolean remoteUrl: string + // For a 'cloud' connection: the persisted Hermes Cloud org (slug or id) the + // connected instance was discovered under, so Settings → Gateway can reopen + // into that org. Empty string for remote/local. + cloudOrg: string } export interface DesktopConnectionConfigInput { - mode: 'local' | 'remote' + mode: 'local' | 'remote' | 'cloud' // When set, the save/apply/test targets this profile's per-profile remote // override instead of the global connection. profile?: null | string remoteAuthMode?: 'oauth' | 'token' remoteToken?: string remoteUrl?: string + // For a 'cloud' connection: the selected Hermes Cloud org (slug or id) to + // persist so Settings can reopen into it. Ignored for remote/local modes. + cloudOrg?: string } export interface DesktopConnectionTestResult { @@ -448,6 +485,55 @@ export interface DesktopOauthLogoutResult { connected: boolean } +// --- Hermes Cloud (cloud-auto-discovery Phase 3) --- + +export interface DesktopCloudStatus { + // The portal base URL the desktop talks to (default or env-overridden). + portalBaseUrl: string + // Whether the OAuth partition holds a live Nous portal (Privy) session — the + // portal authenticates via Privy, so this reflects the privy-token cookie, NOT + // the hermes gateway session cookies. See cookiesHavePrivySession. + signedIn: boolean +} + +// A discovered Hermes Cloud agent — the trimmed DTO from NAS GET /api/agents. +export interface DesktopCloudAgent { + id: string + name: string + status: string + // null until the agent has a provisioned dashboard (show "provisioning…"). + dashboardUrl: string | null + // "active" | "degraded" | "down" | "unknown". + dashboardGatewayState: string +} + +// An org the signed-in user belongs to — for the org picker shown when a +// multi-org user's discovery call needs disambiguation (NAS 409). +export interface DesktopCloudOrg { + id: string + slug: string | null + name: string + isPersonal: boolean + // "OWNER" | "MEMBER". + role: string +} + +// Discovery result: either the agent list, OR a request to pick an org first +// (multi-org user, no org chosen yet). The renderer shows a picker on the +// latter and re-calls discover(org). On the agents branch, `org` echoes the +// authoritatively-resolved org the list was scoped to (from NAS), so the +// desktop persists it without relying on transient picker state. +export type DesktopCloudDiscoverResult = + | { agents: DesktopCloudAgent[]; org?: DesktopCloudOrg | null; needsOrgSelection?: false } + | { needsOrgSelection: true; orgs: DesktopCloudOrg[] } + +export interface DesktopCloudAgentSignInResult { + // The agent gateway base URL the silent sign-in targeted. + baseUrl: string + // Whether the agent's gateway session cookie landed (silent cascade done). + connected: boolean +} + export interface DesktopBootProgress { error: string | null fakeMode: boolean @@ -459,7 +545,7 @@ export interface DesktopBootProgress { } // First-launch install ("bootstrap") event types -- emitted by -// electron/bootstrap-runner.cjs and observed by the renderer install overlay. +// electron/bootstrap-runner.ts and observed by the renderer install overlay. // Mirrors the event shapes emitted by runBootstrap()'s onEvent callback. export interface DesktopBootstrapStageDescriptor { @@ -522,6 +608,10 @@ export interface HermesApiRequest { path: string method?: string body?: unknown + // Single-file multipart upload (FastAPI UploadFile endpoints). Mutually + // exclusive with `body`; bytes transfer over IPC as a structured-clone + // ArrayBuffer. Token-mode backends only. + upload?: { filename: string; contentType?: string; bytes: ArrayBuffer } timeoutMs?: number // Route this REST call to a specific profile's backend. Omit for the primary // (window) backend. Read-only cross-profile data is served by the primary, so @@ -588,6 +678,16 @@ export interface HermesGitBranch { worktreePath: null | string } +// A branch the new worktree can be based on: local heads + remote-tracking +// refs. `isRemote` distinguishes `origin/main` from a local `main` (the UI +// may show a remote glyph); `isDefault` flags origin/HEAD so the dialog can +// preselect it. +export interface HermesGitBaseBranch { + name: string + isRemote: boolean + isDefault: boolean +} + // A single changed path from `git status --porcelain=v2`, classified by state // so the coding rail / switcher can group + open the right diff. export interface HermesRepoStatusFile { diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 573ed2986191..9378d00954fa 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -61,7 +61,7 @@ import type { // model info/options, cron) the moment the backend passes readiness. On a // profile-heavy or remote install these can each take tens of seconds — e.g. // /api/profiles runs list_profiles(), which does a recursive skill-tree walk -// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.cjs) +// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts) // times out a backend that is alive-but-busy, surfacing as a spurious // "Timed out connecting to Hermes backend" that hangs the UI (#48504). // @@ -195,6 +195,109 @@ function profileScoped(): { profile?: string } { return _apiProfile ? { profile: _apiProfile } : {} } +/** Options for a plugin REST call — mirrors the app's own `hermesDesktop.api` + * shape, minus the path (which is namespace-derived). */ +export interface PluginRestOptions { + method?: string + body?: unknown + /** Single-file multipart upload (see HermesApiRequest.upload). */ + upload?: { filename: string; contentType?: string; bytes: ArrayBuffer } + timeoutMs?: number +} + +// Normalize `path` to a leading-slash suffix relative to `/api/plugins/<id>`. +// The namespace is the boundary — reject `..` so a relative segment can't +// normalize out into another plugin's API or a core route. Check the path +// portion only (before any query/hash). +function pluginPathSuffix(caller: string, path: string): string { + const suffix = path.startsWith('/') ? path : `/${path}` + + if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) { + throw new Error(`${caller}: illegal path traversal in "${path}"`) + } + + return suffix +} + +/** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's + * own backend namespace — `path` is relative to `/api/plugins/<pluginId>` + * ('/board' → `/api/plugins/kanban/board`), so a plugin can't address another + * plugin's API or a core route through it. Profile-aware like every desktop + * REST call. Broader reach (core endpoints, another namespace) is the future + * declared-capability seam; today the namespace IS the boundary. */ +export async function pluginRest<T>(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise<T> { + if (!window.hermesDesktop?.api) { + throw new Error('Hermes desktop bridge unavailable') + } + + const suffix = pluginPathSuffix('pluginRest', path) + + return window.hermesDesktop.api<T>({ + path: `/api/plugins/${pluginId}${suffix}`, + method: opts.method, + body: opts.body, + upload: opts.upload, + timeoutMs: opts.timeoutMs, + ...profileScoped() + }) +} + +/** The plugin WebSocket door — the live twin of `pluginRest`, scoped the same + * way: `path` is relative to `/api/plugins/<pluginId>` ('/events' → the + * plugin's own event stream). Token-mode backends auth via the same query + * credential the app's own sockets use; OAuth remotes resolve null (callers + * keep their polling fallback — every consumer must have one anyway, since a + * socket can drop). Auto-reconnects with backoff until disposed. */ +export function pluginSocket(pluginId: string, path: string, onMessage: (data: unknown) => void): () => void { + const suffix = pluginPathSuffix('pluginSocket', path) + + let socket: null | WebSocket = null + let disposed = false + let attempt = 0 + + const connect = async () => { + const connection = await window.hermesDesktop.getConnection().catch(() => null) + + // No bridge / OAuth cookie auth (WS tickets are single-use, core-managed): + // stay on the polling fallback rather than half-working. + if (disposed || !connection || connection.authMode === 'oauth') { + return + } + + const base = connection.baseUrl.replace(/^http/, 'ws') + const join = suffix.includes('?') ? '&' : '?' + socket = new WebSocket( + `${base}/api/plugins/${pluginId}${suffix}${join}token=${encodeURIComponent(connection.token)}` + ) + + socket.onmessage = event => { + attempt = 0 + + try { + onMessage(JSON.parse(String(event.data))) + } catch { + // Non-JSON frame — plugin streams are JSON by contract; skip it. + } + } + + socket.onclose = () => { + socket = null + + if (!disposed) { + attempt += 1 + window.setTimeout(() => void connect(), Math.min(30_000, 1_000 * 2 ** attempt)) + } + } + } + + void connect() + + return () => { + disposed = true + socket?.close() + } +} + export async function listSessions( limit = 40, minMessages = 0, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 0ed3e0ff8810..31c0a734f302 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -90,9 +90,14 @@ export const en: Translations = { retry: 'Retry', repairInstall: 'Repair install', useLocalGateway: 'Use local gateway', + gatewaySettings: 'Gateway settings', + back: 'Back', openLogs: 'Open logs', repairHint: 'Repair re-runs the installer and can take a few minutes on a fresh machine.', - remoteSignInHint: 'Opens the gateway login window. Use local gateway to switch to the bundled backend instead.', + remoteSignInHint: signInLabel => + `Signs out of the saved remote browser session, then opens ${signInLabel}. Use local gateway to switch to the bundled backend instead.`, + signOutAndSignIn: 'Sign out & sign in', + remoteFailureHint: 'Check the gateway URL and sign-in under Gateway settings, or switch to the local gateway.', hideRecentLogs: 'Hide recent logs', showRecentLogs: 'Show recent logs', signedInTitle: 'Signed in', @@ -185,7 +190,9 @@ export const en: Translations = { unmuteHaptics: 'Unmute haptics', openSettings: 'Open settings', openStarmap: 'Open memory graph', - openKeybinds: 'Keyboard shortcuts' + openKeybinds: 'Keyboard shortcuts', + layoutEditor: 'Layout editor', + layoutEditorTitle: 'Layout editor — ⌘-click resets the layout' }, keybinds: { @@ -216,6 +223,7 @@ export const en: Translations = { 'nav.cron': 'Open scheduled jobs', 'nav.agents': 'Open agents', 'session.new': 'New session', + 'session.newTab': 'New session tab', 'session.newWindow': 'New session in window', 'session.next': 'Next session', 'session.prev': 'Previous session', @@ -244,7 +252,8 @@ export const en: Translations = { 'view.prevTerminal': 'Previous terminal', 'view.closeTerminal': 'Close terminal', 'view.terminalSelection': 'Send terminal selection to composer', - 'view.closePreviewTab': 'Close preview tab', + 'view.closeTab': 'Close tab', + 'view.reopenTab': 'Reopen closed tab', 'view.flipPanes': 'Swap sidebar sides', 'appearance.toggleMode': 'Toggle light / dark', 'profile.default': 'Switch to default profile', @@ -311,7 +320,22 @@ export const en: Translations = { mcp: 'MCP', archivedChats: 'Archived Chats', about: 'About', - notifications: 'Notifications' + notifications: 'Notifications', + plugins: 'Plugins' + }, + plugins: { + title: 'Desktop plugins', + blurb: + 'UI extensions loaded into this app — bundled with the build, or dropped into the desktop-plugins folder (including ones Hermes writes). Disabling unloads a plugin live and survives restarts.', + count: n => `${n} installed`, + openFolder: 'Open plugins folder', + rescan: 'Rescan', + reveal: 'Reveal in file manager', + enable: 'Enable', + disable: 'Disable', + failed: 'failed', + empty: 'No desktop plugins installed yet.', + kinds: { bundled: 'bundled', disk: 'on disk', runtime: 'runtime' } }, notifications: { title: 'Notifications', @@ -387,6 +411,8 @@ export const en: Translations = { `Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`, translucencyTitle: 'Window Translucency', translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.', + backdropTitle: 'Chat Backdrop', + backdropDesc: 'The faint statue image behind the conversation.', embedsTitle: 'Inline Embeds', embedsDesc: 'Rich previews load from third-party sites (YouTube, X, …). Ask shows a placeholder until you allow each one; Always loads them automatically; Off keeps plain links.', @@ -530,11 +556,43 @@ export const en: Translations = { envOverrideTitle: 'Environment variables are controlling this desktop session.', envOverrideDesc: 'Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved setting below.', + modeTitle: 'Connection mode', localTitle: 'Local gateway', localDesc: 'Start a private Hermes backend on localhost. This is the default and works offline.', remoteTitle: 'Remote gateway', - remoteDesc: - 'Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.', + remoteDesc: 'Connect this desktop shell to a remote Hermes backend.', + remoteAuthHint: 'Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.', + cloudTitle: 'Hermes Cloud', + cloudDesc: 'Sign in once to Hermes Cloud and pick from the agents on your account — no URL to paste.', + cloudSignInTitle: 'Hermes Cloud', + cloudSignIn: 'Sign in to Hermes Cloud', + cloudSignedIn: 'Signed in to Hermes Cloud', + cloudNeedsSignIn: 'Sign in to Hermes Cloud to discover the agents on your account.', + cloudSignedInDesc: 'You are signed in. Pick an agent below; the session refreshes automatically.', + cloudAgentsTitle: 'Your agents', + cloudOrgPickerTitle: 'Choose an organization', + cloudOrgSelect: 'Select', + cloudOrgChange: 'Change org', + cloudOrgRole: role => `Role: ${role}`, + cloudLoadingAgents: 'Loading your agents…', + cloudNoAgents: { + before: 'No agents found on this account. Create one in the ', + linkText: 'Nous portal', + after: ', then refresh.' + }, + cloudRefresh: 'Refresh', + cloudConnect: 'Connect', + cloudConnecting: 'Connecting…', + cloudDiscoverFailed: 'Could not load your Hermes Cloud agents', + cloudConnectFailed: 'Could not connect to that agent', + cloudSignInFailed: 'Hermes Cloud sign-in failed', + cloudSignedOutTitle: 'Signed out of Hermes Cloud', + cloudSignedOutMessage: 'Cleared the Hermes Cloud session.', + cloudConnectedTitle: 'Connected', + cloudConnectedPill: 'Connected', + cloudConnectedTo: name => `Connected to ${name}.`, + cloudAgentProvisioning: 'Provisioning…', + cloudStatusLabel: status => `Status: ${status}`, remoteUrlTitle: 'Remote URL', remoteUrlDesc: 'Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes.', probing: 'Checking how this gateway authenticates…', @@ -568,7 +626,7 @@ export const en: Translations = { enterUrlFirst: 'Enter a remote URL first.', restartingTitle: 'Gateway connection restarting', savedTitle: 'Gateway settings saved', - restartingMessage: 'Hermes Desktop will reconnect using the saved settings.', + restartingMessage: 'Hermes Desktop will reconnect using the saved settings — the shell stays open.', savedMessage: 'Saved for the next restart.', connectedTo: (baseUrl, version) => `Connected to ${baseUrl}${version ? ` · Hermes ${version}` : ''}`, reachableTitle: 'Remote gateway reachable', @@ -669,6 +727,9 @@ export const en: Translations = { change: 'Change', autoUseMain: 'auto · use main model', providerDefault: '(provider default)', + fallbackAdd: 'Add fallback', + fallbackEmpty: 'No fallback models — the default model is used unless it fails.', + notInCatalog: "isn't in this provider's model list — calls may fall back to a backup.", tasks: { vision: { label: 'Vision', hint: 'Image analysis' }, web_extract: { label: 'Web extract', hint: 'Page summarization' }, @@ -944,6 +1005,7 @@ export const en: Translations = { goTo: 'Go to', goToSession: 'Go to session', branches: 'Branches', + commands: 'Commands', startInBranch: branch => `New conversation in ${branch}`, commandCenter: 'Command Center', appearance: 'Appearance', @@ -1437,7 +1499,10 @@ export const en: Translations = { customPlaceholder: '0 9 * * * or weekdays at 9am', customHint: 'Cron expression, or phrases like "every hour" or "weekdays at 9am".', optional: 'Optional', + promptRequired: 'Prompt is required.', promptScheduleRequired: 'Prompt and schedule are required.', + scheduleRequired: 'Schedule is required.', + scriptOnlyEditHint: 'Script-only job (no AI prompt). Job id:', saveChanges: 'Save changes', createAction: 'Create cron' }, @@ -1541,6 +1606,9 @@ export const en: Translations = { newWorktreeTitle: 'New worktree', newWorktreeDesc: 'Name the branch for this worktree.', branchPlaceholder: 'e.g. my-feature', + branchOff: () => ({ after: '', before: 'branch off ' }), + baseBranchPlaceholder: 'Search branches…', + baseBranchNone: 'No branches found', startWorkFailed: 'Could not create worktree', convertBranch: 'Convert a branch…', convertBranchTitle: 'Convert a branch', @@ -1578,18 +1646,24 @@ export const en: Translations = { rename: 'Rename', archive: 'Archive', newWindow: 'New window', + hideTabBar: 'Hide tab bar', + openInNewTab: 'Open in new tab', + openInSplit: 'Open in split', copyIdFailed: 'Could not copy session ID', actionsFor: title => `Actions for ${title}`, sessionActions: 'Session actions', sessionRunning: 'Session running', needsInput: 'Needs your input', waitingForAnswer: 'Waiting for your answer', + finishedUnread: 'Finished — unread', + backgroundRunning: 'Background task running', handoffOrigin: platform => `Handed off from ${platform}`, renamed: 'Renamed', renameFailed: 'Rename failed', renameTitle: 'Rename session', renameDesc: 'Give this chat a memorable title. Leave empty to clear.', untitledPlaceholder: 'Untitled session', + untitledChat: id => `Chat ${id}`, ageNow: 'now', ageDay: 'd', ageHour: 'h', @@ -1897,6 +1971,10 @@ export const en: Translations = { featuredPitch: 'One subscription, 300+ frontier models — the recommended way to run Hermes', openRouterPitch: 'One key, hundreds of models — a solid default', apiKeyOptions: { + fireworks: { + short: 'direct model API', + description: 'Direct access to models hosted by Fireworks AI.' + }, openrouter: { short: 'one key, many models', description: 'Hosts hundreds of models behind a single key. Good default for new installs.' @@ -1999,7 +2077,9 @@ export const en: Translations = { low: 'Low', medium: 'Medium', high: 'High', + xhigh: 'Extra High', max: 'Max', + ultra: 'Ultra', updateFailed: 'Model option update failed', fastFailed: 'Fast mode update failed' }, @@ -2018,6 +2098,16 @@ export const en: Translations = { viewAllLogs: 'View all logs →', messagingPlatforms: 'Messaging platforms' }, + approvalMode: { + title: 'Approval mode', + ariaLabel: mode => `Approval mode: ${mode}`, + manual: 'Manual', + manualDescription: 'Ask before actions that require approval', + smart: 'Smart', + smartDescription: 'Automatically assess actions and ask when needed', + off: 'Off', + offDescription: 'Run without approval prompts' + }, statusbar: { unknown: 'unknown', restart: 'restart', @@ -2217,6 +2307,52 @@ export const en: Translations = { } }, + zones: { + showHeader: 'Show header', + hideHeader: 'Hide header', + minimize: 'Minimize', + restore: 'Restore', + closeRunningTitle: 'Close running tab?', + closeRunningBody: + 'This chat is still working (or waiting on your input). Closing the tab hides it — the session keeps its progress and can be reopened from the sidebar.', + closeRunningConfirm: 'Close tab', + closeOthers: 'Close others', + closeToRight: 'Close to the right', + closeAll: 'Close all', + split: dir => `Split ${dir}`, + move: dir => `Move ${dir}`, + dirUp: 'up', + dirDown: 'down', + dirLeft: 'left', + dirRight: 'right', + pluginDisabled: pluginId => `Plugin "${pluginId}" disabled`, + pluginDisabledBody: 'Re-enable it in Settings → Plugins to bring the pane back.', + missingPane: paneId => `missing pane: ${paneId}`, + editTitle: 'Layouts', + editHint: 'Pick a layout, or drag panes between zones. Right-click a zone to split.', + reset: 'Reset', + templates: 'Templates', + custom: 'Custom', + newGridLayout: 'New grid layout', + saveCurrentAs: 'Save current arrangement as a template', + nameLayoutPlaceholder: 'Name this layout…', + deletePreset: name => `Delete ${name}`, + zoneEditorTitle: 'Zone editor', + editorHintPre: 'click to split · ', + editorHintPost: ' flips the line · drag across zones to merge · drag shared edges to resize', + templateColumns: 'Columns', + templateRows: 'Rows', + templateGrid: 'Grid', + templatePriority: 'Priority', + zoneTag: index => `zone ${index}`, + mergeZones: count => `Merge ${count} zones`, + customZoneName: count => `Custom ${count}-zone`, + layoutNamePlaceholder: fallback => `Layout name (${fallback})`, + saveApply: 'Save & apply', + notExpressible: 'this arrangement interlocks (pinwheel) — not expressible as nested splits yet', + zoneCount: count => `${count} zones` + }, + assistant: { thread: { loadingSession: 'Loading session', @@ -2277,6 +2413,7 @@ export const en: Translations = { other: 'Other (type your answer)', placeholder: 'Type your answer…', skip: 'Skip', + skipped: 'Skipped', continueLabel: 'Continue' }, tool: { diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 10088833cd62..f6bb2c0d7b36 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -90,10 +90,15 @@ export const ja = defineLocale({ retry: '再試行', repairInstall: 'インストールを修復', useLocalGateway: 'ローカルゲートウェイを使用', + gatewaySettings: 'ゲートウェイ設定', + back: '戻る', openLogs: 'ログを開く', repairHint: '修復はインストーラーを再実行します。新しいマシンでは数分かかる場合があります。', - remoteSignInHint: - 'ゲートウェイのログインウィンドウを開きます。代わりにバンドルされたバックエンドに切り替えるには「ローカルゲートウェイを使用」を選択してください。', + remoteSignInHint: signInLabel => + `保存済みのリモートブラウザセッションからサインアウトし、${signInLabel}を開きます。代わりにバンドルされたバックエンドに切り替えるには「ローカルゲートウェイを使用」を選択してください。`, + signOutAndSignIn: 'サインアウトして再サインイン', + remoteFailureHint: + '「ゲートウェイ設定」でゲートウェイの URL とサインインを確認するか、ローカルゲートウェイに切り替えてください。', hideRecentLogs: '最近のログを非表示', showRecentLogs: '最近のログを表示', signedInTitle: 'サインインしました', @@ -293,6 +298,8 @@ export const ja = defineLocale({ `アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`, translucencyTitle: 'ウィンドウの透過', translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', + backdropTitle: 'チャット背景', + backdropDesc: '会話の背後に表示される淡い彫像の画像。', embedsTitle: 'インライン埋め込み', embedsDesc: 'リッチプレビューは第三者サイト(YouTube、X など)から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。', @@ -1415,7 +1422,10 @@ export const ja = defineLocale({ customPlaceholder: '0 9 * * * または weekdays at 9am', customHint: 'Cron 式、または「every hour」「weekdays at 9am」のようなフレーズ。', optional: '省略可能', + promptRequired: 'プロンプトは必須です。', promptScheduleRequired: 'プロンプトとスケジュールは必須です。', + scheduleRequired: 'スケジュールは必須です。', + scriptOnlyEditHint: 'スクリプトのみのジョブ(AI プロンプトなし)。ジョブ ID:', saveChanges: '変更を保存', createAction: 'Cron を作成' }, @@ -1520,6 +1530,9 @@ export const ja = defineLocale({ newWorktreeTitle: '新しいワークツリー', newWorktreeDesc: 'このワークツリーのブランチ名を入力してください。', branchPlaceholder: '例: my-feature', + branchOff: () => ({ after: ' から分岐', before: '' }), + baseBranchPlaceholder: 'ブランチを検索…', + baseBranchNone: 'ブランチが見つかりません', startWorkFailed: 'ワークツリーを作成できませんでした', convertBranch: 'ブランチを変換…', convertBranchTitle: 'ブランチを変換', @@ -1560,12 +1573,15 @@ export const ja = defineLocale({ sessionRunning: 'セッション実行中', needsInput: '入力が必要です', waitingForAnswer: '回答を待っています', + finishedUnread: '完了 — 未読', + backgroundRunning: 'バックグラウンドタスク実行中', handoffOrigin: platform => `${platform} から引き継ぎ`, renamed: '名前を変更しました', renameFailed: '名前の変更に失敗しました', renameTitle: 'セッションの名前を変更', renameDesc: 'このチャットにわかりやすいタイトルをつけてください。空欄にするとクリアされます。', untitledPlaceholder: '無題のセッション', + untitledChat: id => `セッション ${id}`, ageNow: 'たった今', ageDay: '日', ageHour: '時間', @@ -1875,6 +1891,10 @@ export const ja = defineLocale({ featuredPitch: '1 つのサブスクリプションで 300 以上の最先端モデル — Hermes を実行するための推奨方法', openRouterPitch: '1 つのキーで数百のモデル — 堅実なデフォルト', apiKeyOptions: { + fireworks: { + short: 'モデル API に直接接続', + description: 'Fireworks AI がホストするモデルに直接アクセスします。' + }, openrouter: { short: '1 つのキーで多くのモデル', description: '1 つのキーで数百のモデルをホスト。新規インストールのデフォルトとして最適。' @@ -1977,7 +1997,9 @@ export const ja = defineLocale({ low: '低', medium: '中', high: '高', + xhigh: '特高', max: '最大', + ultra: 'ウルトラ', updateFailed: 'モデルオプションの更新に失敗しました', fastFailed: '高速モードの更新に失敗しました' }, @@ -1996,6 +2018,16 @@ export const ja = defineLocale({ viewAllLogs: 'すべてのログを見る →', messagingPlatforms: 'メッセージングプラットフォーム' }, + approvalMode: { + title: '承認モード', + ariaLabel: mode => `承認モード: ${mode}`, + manual: '手動', + manualDescription: '承認が必要な操作の前に確認します', + smart: 'スマート', + smartDescription: '必要な場合にのみ確認します', + off: 'オフ', + offDescription: '承認プロンプトなしで実行します' + }, statusbar: { unknown: '不明', restart: '再起動', @@ -2196,6 +2228,48 @@ export const ja = defineLocale({ } }, + zones: { + showHeader: 'ヘッダーを表示', + hideHeader: 'ヘッダーを隠す', + minimize: '最小化', + restore: '復元', + closeOthers: '他を閉じる', + closeToRight: '右側を閉じる', + closeAll: 'すべて閉じる', + split: dir => `${dir}に分割`, + move: dir => `${dir}へ移動`, + dirUp: '上', + dirDown: '下', + dirLeft: '左', + dirRight: '右', + pluginDisabled: pluginId => `プラグイン「${pluginId}」を無効化しました`, + pluginDisabledBody: '設定 → プラグイン で再有効化するとペインが戻ります。', + missingPane: paneId => `ペインが見つかりません: ${paneId}`, + editTitle: 'レイアウト', + editHint: 'レイアウトを選ぶか、ペインをゾーン間へドラッグ。ゾーンを右クリックで分割。', + reset: 'リセット', + templates: 'テンプレート', + custom: 'カスタム', + newGridLayout: '新しいグリッドレイアウト', + saveCurrentAs: '現在の配置をテンプレートとして保存', + nameLayoutPlaceholder: 'レイアウト名を入力…', + deletePreset: name => `${name} を削除`, + zoneEditorTitle: 'ゾーンエディター', + editorHintPre: 'クリックで分割 · ', + editorHintPost: ' で線の向きを反転 · ゾーンをまたいでドラッグで結合 · 共有辺をドラッグでリサイズ', + templateColumns: '列', + templateRows: '行', + templateGrid: 'グリッド', + templatePriority: '優先', + zoneTag: index => `ゾーン ${index}`, + mergeZones: count => `${count} 個のゾーンを結合`, + customZoneName: count => `カスタム ${count} ゾーン`, + layoutNamePlaceholder: fallback => `レイアウト名(${fallback})`, + saveApply: '保存して適用', + notExpressible: 'この配置は互いに噛み合っています(風車型)— 入れ子の分割では表現できません', + zoneCount: count => `${count} ゾーン` + }, + assistant: { thread: { loadingSession: 'セッションを読み込み中', @@ -2253,6 +2327,7 @@ export const ja = defineLocale({ other: 'その他(回答を入力)', placeholder: '回答を入力…', skip: 'スキップ', + skipped: 'スキップ済み', continueLabel: '続行' }, tool: { diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 5c10c3d6873f..5962d317221b 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -133,9 +133,13 @@ export interface Translations { retry: string repairInstall: string useLocalGateway: string + gatewaySettings: string + back: string openLogs: string repairHint: string - remoteSignInHint: string + remoteSignInHint: (signInLabel: string) => string + signOutAndSignIn: string + remoteFailureHint: string hideRecentLogs: string showRecentLogs: string signedInTitle: string @@ -228,6 +232,8 @@ export interface Translations { openSettings: string openStarmap: string openKeybinds: string + layoutEditor: string + layoutEditorTitle: string } keybinds: { @@ -273,6 +279,20 @@ export interface Translations { archivedChats: string about: string notifications: string + plugins: string + } + plugins: { + title: string + blurb: string + count: (n: number) => string + openFolder: string + rescan: string + reveal: string + enable: string + disable: string + failed: string + empty: string + kinds: { bundled: string; disk: string; runtime: string } } notifications: { title: string @@ -307,6 +327,8 @@ export interface Translations { uiScaleDesc: (percent: number) => string translucencyTitle: string translucencyDesc: string + backdropTitle: string + backdropDesc: string embedsTitle: string embedsDesc: string embedsAsk: string @@ -442,10 +464,39 @@ export interface Translations { profileConnection: (profile: string) => string envOverrideTitle: string envOverrideDesc: string + modeTitle: string localTitle: string localDesc: string remoteTitle: string remoteDesc: string + remoteAuthHint: string + cloudTitle: string + cloudDesc: string + cloudSignInTitle: string + cloudSignIn: string + cloudSignedIn: string + cloudNeedsSignIn: string + cloudSignedInDesc: string + cloudAgentsTitle: string + cloudOrgPickerTitle: string + cloudOrgSelect: string + cloudOrgChange: string + cloudOrgRole: (role: string) => string + cloudLoadingAgents: string + cloudNoAgents: { before: string; linkText: string; after: string } + cloudRefresh: string + cloudConnect: string + cloudConnecting: string + cloudDiscoverFailed: string + cloudConnectFailed: string + cloudSignInFailed: string + cloudSignedOutTitle: string + cloudSignedOutMessage: string + cloudConnectedTitle: string + cloudConnectedPill: string + cloudConnectedTo: (name: string) => string + cloudAgentProvisioning: string + cloudStatusLabel: (status: string) => string remoteUrlTitle: string remoteUrlDesc: string probing: string @@ -578,6 +629,9 @@ export interface Translations { change: string autoUseMain: string providerDefault: string + fallbackAdd: string + fallbackEmpty: string + notInCatalog: string tasks: Record<string, AuxTaskCopy> } providers: { @@ -829,6 +883,7 @@ export interface Translations { goTo: string goToSession: string branches: string + commands: string startInBranch: (branch: string) => string commandCenter: string appearance: string @@ -1175,7 +1230,10 @@ export interface Translations { customPlaceholder: string customHint: string optional: string + promptRequired: string promptScheduleRequired: string + scheduleRequired: string + scriptOnlyEditHint: string saveChanges: string createAction: string } @@ -1273,6 +1331,9 @@ export interface Translations { newWorktreeTitle: string newWorktreeDesc: string branchPlaceholder: string + branchOff: () => { after: string; before: string } + baseBranchPlaceholder: string + baseBranchNone: string startWorkFailed: string convertBranch: string convertBranchTitle: string @@ -1308,18 +1369,24 @@ export interface Translations { rename: string archive: string newWindow: string + hideTabBar: string + openInNewTab: string + openInSplit: string copyIdFailed: string actionsFor: (title: string) => string sessionActions: string sessionRunning: string needsInput: string waitingForAnswer: string + finishedUnread: string + backgroundRunning: string handoffOrigin: (platform: string) => string renamed: string renameFailed: string renameTitle: string renameDesc: string untitledPlaceholder: string + untitledChat: (id: string) => string ageNow: string ageDay: string ageHour: string @@ -1639,7 +1706,9 @@ export interface Translations { low: string medium: string high: string + xhigh: string max: string + ultra: string updateFailed: string fastFailed: string } @@ -1658,6 +1727,16 @@ export interface Translations { viewAllLogs: string messagingPlatforms: string } + approvalMode: { + title: string + ariaLabel: (mode: string) => string + manual: string + manualDescription: string + smart: string + smartDescription: string + off: string + offDescription: string + } statusbar: { unknown: string restart: string @@ -1854,6 +1933,51 @@ export interface Translations { } } + zones: { + showHeader: string + hideHeader: string + minimize: string + restore: string + closeRunningTitle: string + closeRunningBody: string + closeRunningConfirm: string + closeOthers: string + closeToRight: string + closeAll: string + split: (dir: string) => string + move: (dir: string) => string + dirUp: string + dirDown: string + dirLeft: string + dirRight: string + pluginDisabled: (pluginId: string) => string + pluginDisabledBody: string + missingPane: (paneId: string) => string + editTitle: string + editHint: string + reset: string + templates: string + custom: string + newGridLayout: string + saveCurrentAs: string + nameLayoutPlaceholder: string + deletePreset: (name: string) => string + zoneEditorTitle: string + editorHintPre: string + editorHintPost: string + templateColumns: string + templateRows: string + templateGrid: string + templatePriority: string + zoneTag: (index: number) => string + mergeZones: (count: number) => string + customZoneName: (count: number) => string + layoutNamePlaceholder: (fallback: string) => string + saveApply: string + notExpressible: string + zoneCount: (count: number) => string + } + assistant: { thread: { loadingSession: string @@ -1909,6 +2033,7 @@ export interface Translations { other: string placeholder: string skip: string + skipped: string continueLabel: string } tool: { diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 095bf6e286f6..a02abba62e0a 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -88,9 +88,14 @@ export const zhHant = defineLocale({ retry: '重試', repairInstall: '修復安裝', useLocalGateway: '使用本機閘道', + gatewaySettings: '閘道設定', + back: '返回', openLogs: '開啟記錄', repairHint: '修復會重新執行安裝程式,在新機器上可能需要幾分鐘。', - remoteSignInHint: '開啟閘道登入視窗。使用本機閘道可切換至內建後端。', + remoteSignInHint: signInLabel => + `先登出已儲存的遠端瀏覽器工作階段,然後開啟${signInLabel}。使用本機閘道可切換至內建後端。`, + signOutAndSignIn: '登出並重新登入', + remoteFailureHint: '在「閘道設定」中檢查閘道 URL 與登入,或切換至本機閘道。', hideRecentLogs: '隱藏最近記錄', showRecentLogs: '顯示最近記錄', signedInTitle: '已登入', @@ -281,9 +286,12 @@ export const zhHant = defineLocale({ toolViewTitle: '工具呼叫顯示', toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。', uiScaleTitle: '介面縮放', - uiScaleDesc: (percent: number) => `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, + uiScaleDesc: (percent: number) => + `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, translucencyTitle: '視窗透明', translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', + backdropTitle: '聊天背景', + backdropDesc: '對話後方那張淡淡的雕像圖片。', embedsTitle: '內嵌預覽', embedsDesc: '豐富預覽會從第三方網站(YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。', @@ -1367,7 +1375,10 @@ export const zhHant = defineLocale({ customPlaceholder: '0 9 * * * 或 weekdays at 9am', customHint: 'Cron 表達式,或類似「每小時」「工作日上午 9 點」的短語。', optional: '選填', + promptRequired: '提示詞為必填項目。', promptScheduleRequired: '提示詞和排程為必填項目。', + scheduleRequired: '排程為必填項目。', + scriptOnlyEditHint: '僅腳本任務(無 AI 提示詞)。任務 ID:', saveChanges: '儲存變更', createAction: '建立排程工作' }, @@ -1470,6 +1481,9 @@ export const zhHant = defineLocale({ newWorktreeTitle: '新增工作樹', newWorktreeDesc: '為這個工作樹命名分支。', branchPlaceholder: '例如 my-feature', + branchOff: () => ({ after: ' 分支', before: '從 ' }), + baseBranchPlaceholder: '搜尋分支…', + baseBranchNone: '未找到分支', startWorkFailed: '無法建立工作樹', convertBranch: '轉換分支…', convertBranchTitle: '轉換分支', @@ -1509,12 +1523,15 @@ export const zhHant = defineLocale({ sessionRunning: '工作階段執行中', needsInput: '需要您的輸入', waitingForAnswer: '等待您的回答', + finishedUnread: '已完成 — 未讀', + backgroundRunning: '背景任務執行中', handoffOrigin: platform => `從 ${platform} 轉接`, renamed: '已重新命名', renameFailed: '重新命名失敗', renameTitle: '重新命名工作階段', renameDesc: '為此聊天取一個好記的標題。留空則清除。', untitledPlaceholder: '未命名工作階段', + untitledChat: id => `工作階段 ${id}`, ageNow: '剛才', ageDay: '天', ageHour: '時', @@ -1818,6 +1835,7 @@ export const zhHant = defineLocale({ featuredPitch: '一個訂閱,300+ 前沿模型 — 執行 Hermes 的建議方式', openRouterPitch: '一個金鑰,數百個模型 — 穩定的預設選擇', apiKeyOptions: { + fireworks: { short: '直接模型 API', description: '直接存取 Fireworks AI 託管的模型。' }, openrouter: { short: '一個金鑰,多個模型', description: '用一個金鑰存取數百個模型。適合新安裝的預設選擇。' }, openai: { short: 'GPT 等級模型', description: '直接存取 OpenAI 模型。' }, gemini: { short: 'Gemini 模型', description: '直接存取 Google Gemini 模型。' }, @@ -1914,7 +1932,9 @@ export const zhHant = defineLocale({ low: '低', medium: '中', high: '高', + xhigh: '極高', max: '最高', + ultra: '超高', updateFailed: '模型選項更新失敗', fastFailed: '快速模式更新失敗' }, @@ -1933,6 +1953,16 @@ export const zhHant = defineLocale({ viewAllLogs: '查看全部記錄 →', messagingPlatforms: '訊息平台' }, + approvalMode: { + title: '核准模式', + ariaLabel: mode => `核准模式:${mode}`, + manual: '手動', + manualDescription: '執行需要核准的操作前詢問', + smart: '智慧', + smartDescription: '自動評估操作,並在需要時詢問', + off: '關閉', + offDescription: '不顯示核准提示,直接執行' + }, statusbar: { unknown: '未知', restart: '重新啟動', @@ -2130,6 +2160,48 @@ export const zhHant = defineLocale({ } }, + zones: { + showHeader: '顯示標題列', + hideHeader: '隱藏標題列', + minimize: '最小化', + restore: '還原', + closeOthers: '關閉其他', + closeToRight: '關閉右側', + closeAll: '全部關閉', + split: dir => `向${dir}分割`, + move: dir => `向${dir}移動`, + dirUp: '上', + dirDown: '下', + dirLeft: '左', + dirRight: '右', + pluginDisabled: pluginId => `外掛「${pluginId}」已停用`, + pluginDisabledBody: '在 設定 → 外掛 中重新啟用即可恢復面板。', + missingPane: paneId => `缺少面板:${paneId}`, + editTitle: '版面配置', + editHint: '選擇一個版面配置,或在區域之間拖曳面板。右鍵點擊區域可分割。', + reset: '重設', + templates: '範本', + custom: '自訂', + newGridLayout: '新增網格版面', + saveCurrentAs: '將目前排列儲存為範本', + nameLayoutPlaceholder: '為版面命名…', + deletePreset: name => `刪除 ${name}`, + zoneEditorTitle: '區域編輯器', + editorHintPre: '點擊分割 · ', + editorHintPost: ' 翻轉分割線 · 拖曳跨越多個區域可合併 · 拖曳共用邊可調整大小', + templateColumns: '欄', + templateRows: '列', + templateGrid: '網格', + templatePriority: '優先', + zoneTag: index => `區域 ${index}`, + mergeZones: count => `合併 ${count} 個區域`, + customZoneName: count => `自訂 ${count} 區`, + layoutNamePlaceholder: fallback => `版面名稱(${fallback})`, + saveApply: '儲存並套用', + notExpressible: '此排列互相咬合(風車形)——暫時無法表示為巢狀分割', + zoneCount: count => `${count} 個區域` + }, + assistant: { thread: { loadingSession: '正在載入工作階段', @@ -2185,6 +2257,7 @@ export const zhHant = defineLocale({ other: '其他(輸入您的答案)', placeholder: '輸入您的答案…', skip: '略過', + skipped: '已略過', continueLabel: '繼續' }, tool: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 411bfcf6ac14..1422fb886d7b 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -88,9 +88,14 @@ export const zh: Translations = { retry: '重试', repairInstall: '修复安装', useLocalGateway: '使用本地网关', + gatewaySettings: '网关设置', + back: '返回', openLogs: '打开日志', repairHint: '修复会重新运行安装器,在新机器上可能需要几分钟。', - remoteSignInHint: '打开网关登录窗口。也可以使用本地网关切换到随应用提供的后端。', + remoteSignInHint: signInLabel => + `先退出已保存的远程浏览器会话,然后打开${signInLabel}。也可以使用本地网关切换到随应用提供的后端。`, + signOutAndSignIn: '退出并重新登录', + remoteFailureHint: '在“网关设置”中检查网关 URL 和登录,或切换到本地网关。', hideRecentLogs: '隐藏最近日志', showRecentLogs: '显示最近日志', signedInTitle: '已登录', @@ -180,7 +185,9 @@ export const zh: Translations = { unmuteHaptics: '开启触感反馈', openSettings: '打开设置', openStarmap: '打开记忆图谱', - openKeybinds: '键盘快捷键' + openKeybinds: '键盘快捷键', + layoutEditor: '布局编辑器', + layoutEditorTitle: '布局编辑器 — ⌘ 点击重置布局' }, keybinds: { @@ -211,6 +218,7 @@ export const zh: Translations = { 'nav.cron': '打开定时任务', 'nav.agents': '打开智能体', 'session.new': '新建会话', + 'session.newTab': '新建会话标签', 'session.newWindow': '在新窗口中新建会话', 'session.next': '下一个会话', 'session.prev': '上一个会话', @@ -235,7 +243,8 @@ export const zh: Translations = { 'view.showFiles': '显示文件浏览器', 'view.showTerminal': '显示终端', 'view.terminalSelection': '将终端选区发送到输入框', - 'view.closePreviewTab': '关闭预览标签', + 'view.closeTab': '关闭标签', + 'view.reopenTab': '重新打开已关闭的标签', 'view.flipPanes': '交换侧边栏位置', 'appearance.toggleMode': '切换浅色/深色', 'profile.default': '切换到默认配置', @@ -302,7 +311,22 @@ export const zh: Translations = { mcp: 'MCP', archivedChats: '已归档对话', about: '关于', - notifications: '通知' + notifications: '通知', + plugins: '插件' + }, + plugins: { + title: '桌面插件', + blurb: + '加载到此应用中的界面扩展——随构建捆绑,或放入 desktop-plugins 文件夹(包括 Hermes 编写的插件)。禁用会即时卸载插件并在重启后保持。', + count: n => `已安装 ${n} 个`, + openFolder: '打开插件文件夹', + rescan: '重新扫描', + reveal: '在文件管理器中显示', + enable: '启用', + disable: '禁用', + failed: '失败', + empty: '尚未安装桌面插件。', + kinds: { bundled: '内置', disk: '磁盘', runtime: '运行时' } }, notifications: { title: '通知', @@ -372,9 +396,12 @@ export const zh: Translations = { toolViewTitle: '工具调用显示', toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。', uiScaleTitle: '界面缩放', - uiScaleDesc: (percent: number) => `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, + uiScaleDesc: (percent: number) => + `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, translucencyTitle: '窗口透明', translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', + backdropTitle: '聊天背景', + backdropDesc: '对话后方那张淡淡的雕像图片。', embedsTitle: '内嵌预览', embedsDesc: '富预览会从第三方网站(YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。', @@ -719,11 +746,43 @@ export const zh: Translations = { profileConnection: profile => `仅当“${profile}”是当前 profile 时使用此连接。设为本地即可继承默认连接。`, envOverrideTitle: '环境变量正在控制此桌面会话。', envOverrideDesc: '取消设置 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 后才会使用下面保存的设置。', + modeTitle: '连接模式', localTitle: '本地网关', localDesc: '在 localhost 启动私有 Hermes 后端。这是默认方式,并且可离线工作。', remoteTitle: '远程网关', - remoteDesc: - '将此桌面外壳连接到远程 Hermes 后端。托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。', + remoteDesc: '将此桌面外壳连接到远程 Hermes 后端。', + remoteAuthHint: '托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。', + cloudTitle: 'Hermes Cloud', + cloudDesc: '只需登录 Hermes Cloud 一次,即可从你账户下的智能体中选择——无需粘贴 URL。', + cloudSignInTitle: 'Hermes Cloud', + cloudSignIn: '登录 Hermes Cloud', + cloudSignedIn: '已登录 Hermes Cloud', + cloudNeedsSignIn: '登录 Hermes Cloud 以发现你账户下的智能体。', + cloudSignedInDesc: '你已登录。在下方选择一个智能体;会话会自动刷新。', + cloudAgentsTitle: '你的智能体', + cloudOrgPickerTitle: '选择一个组织', + cloudOrgSelect: '选择', + cloudOrgChange: '切换组织', + cloudOrgRole: role => `角色:${role}`, + cloudLoadingAgents: '正在加载你的智能体…', + cloudNoAgents: { + before: '此账户下未找到智能体。请在', + linkText: 'Nous 门户', + after: '中创建一个,然后刷新。' + }, + cloudRefresh: '刷新', + cloudConnect: '连接', + cloudConnecting: '正在连接…', + cloudDiscoverFailed: '无法加载你的 Hermes Cloud 智能体', + cloudConnectFailed: '无法连接到该智能体', + cloudSignInFailed: 'Hermes Cloud 登录失败', + cloudSignedOutTitle: '已退出 Hermes Cloud', + cloudSignedOutMessage: '已清除 Hermes Cloud 会话。', + cloudConnectedTitle: '已连接', + cloudConnectedPill: '已连接', + cloudConnectedTo: name => `已连接到 ${name}。`, + cloudAgentProvisioning: '正在配置…', + cloudStatusLabel: status => `状态:${status}`, remoteUrlTitle: '远程 URL', remoteUrlDesc: '远程 dashboard 后端的基础 URL。支持路径前缀,例如 /hermes。', probing: '正在检查此网关的认证方式…', @@ -756,7 +815,7 @@ export const zh: Translations = { enterUrlFirst: '请先输入远程 URL。', restartingTitle: '网关连接正在重启', savedTitle: '网关设置已保存', - restartingMessage: 'Hermes Desktop 将使用已保存设置重新连接。', + restartingMessage: 'Hermes Desktop 将使用已保存设置重新连接(界面保持打开)。', savedMessage: '已保存,下一次重启生效。', connectedTo: (baseUrl, version) => `已连接到 ${baseUrl}${version ? ` · Hermes ${version}` : ''}`, reachableTitle: '远程网关可访问', @@ -857,6 +916,9 @@ export const zh: Translations = { change: '更改', autoUseMain: '自动 · 使用主模型', providerDefault: '(提供方默认)', + fallbackAdd: '添加备用模型', + fallbackEmpty: '未配置备用模型 — 默认模型失败时才会使用备用模型。', + notInCatalog: '不在该提供方的模型列表中 — 调用可能回退到备用模型。', tasks: { vision: { label: '视觉', hint: '图片分析' }, web_extract: { label: '网页提取', hint: '页面总结' }, @@ -1125,6 +1187,7 @@ export const zh: Translations = { goTo: '前往', goToSession: '前往会话', branches: '分支', + commands: '命令', startInBranch: branch => `在 ${branch} 中开始新对话`, commandCenter: '命令中心', appearance: '外观', @@ -1614,7 +1677,10 @@ export const zh: Translations = { customPlaceholder: '0 9 * * * 或 weekdays at 9am', customHint: 'Cron 表达式,或类似"每小时""工作日上午 9 点"的短语。', optional: '可选', + promptRequired: '提示词为必填项。', promptScheduleRequired: '提示词和排程为必填项。', + scheduleRequired: '排程为必填项。', + scriptOnlyEditHint: '仅脚本任务(无 AI 提示词)。任务 ID:', saveChanges: '保存更改', createAction: '创建定时任务' }, @@ -1717,6 +1783,9 @@ export const zh: Translations = { newWorktreeTitle: '新建工作树', newWorktreeDesc: '为这个工作树命名分支。', branchPlaceholder: '例如 my-feature', + branchOff: () => ({ after: ' 分支', before: '从 ' }), + baseBranchPlaceholder: '搜索分支…', + baseBranchNone: '未找到分支', startWorkFailed: '无法创建工作树', convertBranch: '转换分支…', convertBranchTitle: '转换分支', @@ -1753,18 +1822,24 @@ export const zh: Translations = { rename: '重命名', archive: '归档', newWindow: '新窗口', + hideTabBar: '隐藏标签栏', + openInNewTab: '在新标签页中打开', + openInSplit: '在分屏中打开', copyIdFailed: '无法复制会话 ID', actionsFor: title => `${title} 的操作`, sessionActions: '会话操作', sessionRunning: '会话运行中', needsInput: '需要你输入', waitingForAnswer: '正在等待你的回答', + finishedUnread: '已完成 — 未读', + backgroundRunning: '后台任务运行中', handoffOrigin: platform => `从 ${platform} 转接`, renamed: '已重命名', renameFailed: '重命名失败', renameTitle: '重命名会话', renameDesc: '给这个对话起一个好记的标题。留空则清除。', untitledPlaceholder: '无标题会话', + untitledChat: id => `会话 ${id}`, ageNow: '刚刚', ageDay: '天', ageHour: '时', @@ -2069,6 +2144,7 @@ export const zh: Translations = { featuredPitch: '一个订阅,300+ 前沿模型 — 运行 Hermes 的推荐方式', openRouterPitch: '一个密钥,数百个模型 — 稳妥的默认选择', apiKeyOptions: { + fireworks: { short: '直接模型 API', description: '直接访问 Fireworks AI 托管的模型。' }, openrouter: { short: '一个密钥,多个模型', description: '用一个密钥访问数百个模型。适合新安装的默认选择。' }, openai: { short: 'GPT 级模型', description: '直接访问 OpenAI 模型。' }, gemini: { short: 'Gemini 模型', description: '直接访问 Google Gemini 模型。' }, @@ -2166,7 +2242,9 @@ export const zh: Translations = { low: '低', medium: '中', high: '高', + xhigh: '极高', max: '最高', + ultra: '超高', updateFailed: '模型选项更新失败', fastFailed: '快速模式更新失败' }, @@ -2185,6 +2263,16 @@ export const zh: Translations = { viewAllLogs: '查看全部日志 →', messagingPlatforms: '消息平台' }, + approvalMode: { + title: '审批模式', + ariaLabel: mode => `审批模式:${mode}`, + manual: '手动', + manualDescription: '执行需要审批的操作前询问', + smart: '智能', + smartDescription: '自动评估操作,并在需要时询问', + off: '关闭', + offDescription: '不显示审批提示,直接运行' + }, statusbar: { unknown: '未知', restart: '重启', @@ -2382,6 +2470,51 @@ export const zh: Translations = { } }, + zones: { + showHeader: '显示标题栏', + hideHeader: '隐藏标题栏', + minimize: '最小化', + restore: '还原', + closeRunningTitle: '关闭正在运行的标签?', + closeRunningBody: '此对话仍在运行(或正在等待你的输入)。关闭标签只会隐藏它——会话将保留进度,可从侧边栏重新打开。', + closeRunningConfirm: '关闭标签', + closeOthers: '关闭其他', + closeToRight: '关闭右侧', + closeAll: '全部关闭', + split: dir => `向${dir}拆分`, + move: dir => `向${dir}移动`, + dirUp: '上', + dirDown: '下', + dirLeft: '左', + dirRight: '右', + pluginDisabled: pluginId => `插件“${pluginId}”已禁用`, + pluginDisabledBody: '在 设置 → 插件 中重新启用即可恢复面板。', + missingPane: paneId => `缺少面板:${paneId}`, + editTitle: '布局', + editHint: '选择一个布局,或在区域之间拖动面板。右键点击区域可拆分。', + reset: '重置', + templates: '模板', + custom: '自定义', + newGridLayout: '新建网格布局', + saveCurrentAs: '将当前排列保存为模板', + nameLayoutPlaceholder: '为布局命名…', + deletePreset: name => `删除 ${name}`, + zoneEditorTitle: '区域编辑器', + editorHintPre: '点击拆分 · ', + editorHintPost: ' 翻转分割线 · 拖过多个区域可合并 · 拖动共享边可调整大小', + templateColumns: '列', + templateRows: '行', + templateGrid: '网格', + templatePriority: '优先', + zoneTag: index => `区域 ${index}`, + mergeZones: count => `合并 ${count} 个区域`, + customZoneName: count => `自定义 ${count} 区`, + layoutNamePlaceholder: fallback => `布局名称(${fallback})`, + saveApply: '保存并应用', + notExpressible: '此排列互相咬合(风车形)——暂无法表示为嵌套拆分', + zoneCount: count => `${count} 个区域` + }, + assistant: { thread: { loadingSession: '正在加载会话', @@ -2439,6 +2572,7 @@ export const zh: Translations = { other: '其他 (输入你的答案)', placeholder: '输入你的答案…', skip: '跳过', + skipped: '已跳过', continueLabel: '继续' }, tool: { diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index c6829420a1de..c622b90d85e7 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -46,6 +46,7 @@ export type GatewayEventPayload = { reasoning_effort?: string service_tier?: string fast?: boolean + approval_mode?: string yolo?: boolean running?: boolean cwd?: string @@ -66,6 +67,7 @@ export type GatewayEventPayload = { description?: string // False when a tirith content-security warning forbids a permanent allow. allow_permanent?: boolean + smart_denied?: boolean // secret.request (skill credential capture) env_var?: string prompt?: string diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 06d8e4c3265b..0e200a50da8e 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -55,7 +55,8 @@ export function createClientSessionState( pendingBranchGroup: null, interrupted: false, needsInput: false, - turnStartedAt: null + turnStartedAt: null, + usage: null } } @@ -383,3 +384,61 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage { } } as ThreadMessage } + +export type ToolMergeCache = WeakMap< + ChatMessage, + { merged: ChatMessage; parts: ChatMessagePart[]; prev: ChatMessage; prevParts: ChatMessagePart[] } +> + +export function createToolMergeCache(): ToolMergeCache { + return new WeakMap() +} + +// A settled assistant message with only tool calls — no prose, no reasoning. +// The model routinely emits a follow-up batch of calls as its own text-less +// message; on screen it looks like one continuous run, but assistant-ui can't +// group tool calls across a message boundary. +function isToolOnlyAssistant(message: ChatMessage): boolean { + return ( + message.role === 'assistant' && + !message.pending && + !message.error && + !message.hidden && + message.parts.length > 0 && + message.parts.every(part => part.type === 'tool-call') + ) +} + +/** + * Fold each settled tool-only assistant message into the preceding assistant + * message so its calls join that message's tool group (and can collapse into + * the auto-scrolling window). Render-only — never mutates the `$messages` store + * — and settle-only: pending messages are left alone, so a live turn is never + * merged/un-merged mid-stream. `cache` keys merged results by source identity, + * so a stable turn yields stable merged objects (no re-render churn). + */ +export function coalesceToolOnlyAssistants(messages: ChatMessage[], cache: ToolMergeCache): ChatMessage[] { + const out: ChatMessage[] = [] + + for (const message of messages) { + const prev = out.at(-1) + + if (prev && prev.role === 'assistant' && !prev.pending && !prev.hidden && isToolOnlyAssistant(message)) { + const cached = cache.get(message) + + const merged = + cached && cached.prev === prev && cached.prevParts === prev.parts && cached.parts === message.parts + ? cached.merged + : { ...prev, parts: [...prev.parts, ...message.parts] } + + cache.set(message, { merged, parts: message.parts, prev, prevParts: prev.parts }) + out[out.length - 1] = merged + + continue + } + + out.push(message) + } + + return out +} diff --git a/apps/desktop/src/lib/composer-input-sanitize.test.ts b/apps/desktop/src/lib/composer-input-sanitize.test.ts new file mode 100644 index 000000000000..2695f942e518 --- /dev/null +++ b/apps/desktop/src/lib/composer-input-sanitize.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import { + collapseRepeatedInputArtifacts, + sanitizeComposerInput, + stripLeakedBracketedPasteWrappers +} from './composer-input-sanitize' + +describe('stripLeakedBracketedPasteWrappers', () => { + it('leaves plain text unchanged', () => { + expect(stripLeakedBracketedPasteWrappers('hello world')).toBe('hello world') + }) + + it('strips canonical escape wrappers', () => { + expect(stripLeakedBracketedPasteWrappers('\x1b[200~hello\x1b[201~')).toBe('hello') + }) + + it('keeps embedded literal bracket forms', () => { + const text = 'literal[200~tag and literal[201~tag should stay' + expect(stripLeakedBracketedPasteWrappers(text)).toBe(text) + }) +}) + +describe('collapseRepeatedInputArtifacts', () => { + it('removes the desktop corruption tail from #62557', () => { + const prefix = '需要时随时叫我。' + const tail = '[e~[[e' + '~[[e'.repeat(20) + expect(collapseRepeatedInputArtifacts(prefix + tail)).toBe(prefix) + }) + + it('preserves a mid-string marker followed by valid suffix', () => { + const text = 'notes ~[[e more text here' + expect(collapseRepeatedInputArtifacts(text)).toBe(text) + }) + + it('preserves trailing punctuation that is not the corruption signature', () => { + expect(collapseRepeatedInputArtifacts('wait....')).toBe('wait....') + }) + + it('does not strip when fewer than minRepeats markers appear at the tail', () => { + const text = 'hello~[[e~[[e' + expect(collapseRepeatedInputArtifacts(text)).toBe(text) + }) +}) + +describe('sanitizeComposerInput', () => { + it('normalizes wrappers and repeated artifact tails together', () => { + const corrupted = 'hello[' + '~[[e'.repeat(8) + expect(sanitizeComposerInput(corrupted)).toBe('hello') + }) +}) diff --git a/apps/desktop/src/lib/composer-input-sanitize.ts b/apps/desktop/src/lib/composer-input-sanitize.ts new file mode 100644 index 000000000000..b09ed96375e3 --- /dev/null +++ b/apps/desktop/src/lib/composer-input-sanitize.ts @@ -0,0 +1,74 @@ +/** + * Strip terminal bracketed-paste leaks and repeated artifact tails from composer + * text before it is shown in the UI or sent to the gateway. + * + * Mirrors hermes_cli/input_sanitize.py (CLI/TUI gateway defensive path). + */ + +const BRACKETED_PASTE_BOUNDARY_START = /(^|[\s\n>:\])])\[200~/g +const BRACKETED_PASTE_BOUNDARY_END = /\[201~(?=$|[\s\n<[():;.,!?])/g +const BRACKETED_PASTE_DEGRADED_START = /(^|[\s\n>:\])])00~/g +const BRACKETED_PASTE_DEGRADED_END = /01~(?=$|[\s\n<[():;.,!?])/g + +const DESKTOP_PASTE_ARTIFACT = '~[[e' + +/** Strip leaked bracketed-paste wrapper markers from user-visible text. */ +export function stripLeakedBracketedPasteWrappers(text: string): string { + if (!text) { + return text + } + + let cleaned = text + // eslint-disable-next-line no-control-regex -- terminal data may contain control chars + .replace(/\x1b\[200~/g, '') + // eslint-disable-next-line no-control-regex -- terminal data may contain control chars + .replace(/\x1b\[201~/g, '') + .replace(/\^\[\[200~/g, '') + .replace(/\^\[\[201~/g, '') + + cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_START, '$1') + cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_END, '') + cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_START, '$1') + cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_END, '') + + return cleaned +} + +/** Drop a trailing run of the desktop ~[[e corruption signature (#62557). */ +export function collapseRepeatedInputArtifacts(text: string, minRepeats = 4): string { + if (!text) { + return text + } + + const marker = DESKTOP_PASTE_ARTIFACT + let index = text.length + let repeatCount = 0 + + while (index >= marker.length && text.slice(index - marker.length, index) === marker) { + repeatCount += 1 + index -= marker.length + } + + if (repeatCount < minRepeats) { + return text + } + + let start = index + + if (start >= 2 && text.slice(start - 2, start) === '[e') { + start -= 2 + } else if (start >= 1 && text[start - 1] === '[') { + start -= 1 + } + + return text.slice(0, start) +} + +/** Normalize composer text before submit or draft persistence. */ +export function sanitizeComposerInput(text: string): string { + if (!text) { + return text + } + + return collapseRepeatedInputArtifacts(stripLeakedBracketedPasteWrappers(text)) +} diff --git a/apps/desktop/src/lib/desktop-git.ts b/apps/desktop/src/lib/desktop-git.ts index 1a12c3b8abe3..ddc14df84dae 100644 --- a/apps/desktop/src/lib/desktop-git.ts +++ b/apps/desktop/src/lib/desktop-git.ts @@ -1,4 +1,5 @@ import type { + HermesGitBaseBranch, HermesGitBranch, HermesGitWorktree, HermesRepoStatus, @@ -58,6 +59,9 @@ const remoteGit: GitBridge = { branchList: async repoPath => (await gitGet<{ branches: HermesGitBranch[] }>('branches', { path: repoPath })).branches, + baseBranchList: async repoPath => + (await gitGet<{ branches: HermesGitBaseBranch[] }>('base-branches', { path: repoPath })).branches, + repoStatus: repoPath => gitGet<HermesRepoStatus | null>('status', { path: repoPath }), fileDiff: async (repoPath, filePath) => diff --git a/apps/desktop/src/lib/drag-ghost.ts b/apps/desktop/src/lib/drag-ghost.ts new file mode 100644 index 000000000000..f07a9835c7dd --- /dev/null +++ b/apps/desktop/src/lib/drag-ghost.ts @@ -0,0 +1,43 @@ +/** + * A flat, pointer-following drag chip — the shared "what am I holding" + * affordance for in-app pointer drags. Plain DOM (no React) so it survives a + * pointer-capture drag without re-renders and tears down synchronously on Esc. + * + * Flat by design: a solid app surface with the dragged item's label, no + * border / radius / shadow, dimmed — it copies the real row/tab it represents + * rather than reading as a separate pill. Any pointer drag whose source does + * not stay visibly "held" can reuse this (the drag primitive in + * `pane-shell/tree/renderer/drag-session.ts`, and anything built on it). + */ + +/** How far (px) the chip trails the pointer so it never sits under the cursor. */ +const OFFSET_X = 14 +const OFFSET_Y = 12 + +export interface DragGhost { + /** Reposition the chip near the current pointer point. */ + moveTo(x: number, y: number): void + /** Remove the chip from the DOM. Idempotent. */ + destroy(): void +} + +export function createDragGhost(label: string): DragGhost { + const el = document.createElement('div') + + el.textContent = label + el.style.cssText = + 'position:fixed;left:0;top:0;z-index:9999;pointer-events:none;max-width:16rem;overflow:hidden;' + + 'text-overflow:ellipsis;white-space:nowrap;padding:0.25rem 0.625rem;opacity:0.6;' + + 'background:var(--ui-sidebar-surface-background,var(--dt-card));color:var(--ui-text-primary);' + + 'font-size:0.75rem;font-weight:500;will-change:transform' + document.body.appendChild(el) + + return { + moveTo(x, y) { + el.style.transform = `translate3d(${x + OFFSET_X}px, ${y + OFFSET_Y}px, 0)` + }, + destroy() { + el.remove() + } + } +} diff --git a/apps/desktop/src/lib/escape-layers.test.ts b/apps/desktop/src/lib/escape-layers.test.ts new file mode 100644 index 000000000000..9c5d3af54a73 --- /dev/null +++ b/apps/desktop/src/lib/escape-layers.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from './escape-layers' + +describe('escape-layers', () => { + it('reports top when nothing is registered', () => { + expect(isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)).toBe(true) + }) + + it('a lower layer yields to a higher open one, and reclaims top when it closes', () => { + const releaseOverlay = pushEscapeLayer(ESCAPE_PRIORITY.overlay) + + // Narrow overlay is open under a full-page overlay — it must not act. + expect(isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)).toBe(false) + // The overlay itself is top. + expect(isTopEscapeLayer(ESCAPE_PRIORITY.overlay)).toBe(true) + + releaseOverlay() + expect(isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)).toBe(true) + }) + + it('equal-or-higher priority counts as top (ties act)', () => { + const release = pushEscapeLayer(ESCAPE_PRIORITY.zoneEditor) + expect(isTopEscapeLayer(ESCAPE_PRIORITY.zoneEditor)).toBe(true) + expect(isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)).toBe(false) + release() + }) + + it('tracks the max across several open layers', () => { + const releases = [ + pushEscapeLayer(ESCAPE_PRIORITY.narrowOverlay), + pushEscapeLayer(ESCAPE_PRIORITY.layoutEdit), + pushEscapeLayer(ESCAPE_PRIORITY.zoneEditor) + ] + + expect(isTopEscapeLayer(ESCAPE_PRIORITY.zoneEditor)).toBe(true) + expect(isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)).toBe(false) + + // Close the zone editor — layout edit becomes top. + releases[2]() + expect(isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)).toBe(true) + + releases.forEach(release => release()) + expect(isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)).toBe(true) + }) +}) diff --git a/apps/desktop/src/lib/escape-layers.ts b/apps/desktop/src/lib/escape-layers.ts new file mode 100644 index 000000000000..bc9bb0610422 --- /dev/null +++ b/apps/desktop/src/lib/escape-layers.ts @@ -0,0 +1,53 @@ +/** + * Ordered Escape ownership for the app's transient window-level layers. + * + * Several surfaces bind their own `window` `keydown` Escape handler (narrow-pane + * reveal, layout edit mode, the zone editor, full-page overlays). Without a + * shared order a single Escape fired all of them at once — closing a pinned + * pane *and* exiting edit mode, or dismissing an overlay *and* the pane beneath + * it. Radix dialogs already stop propagation / preventDefault, so this is only + * about the app's own handlers. + * + * Contract for a layer handler: + * 1. bail if `event.defaultPrevented` (a higher, propagation-stopping layer + * — a Radix dialog — already handled it); + * 2. bail unless `isTopEscapeLayer(myPriority)` (a higher app layer is open); + * 3. otherwise act and `event.preventDefault()`. + * + * A layer registers its priority (via `pushEscapeLayer`) only while it's open. + */ + +// Higher number = closer to the user. Gaps leave room to slot new layers. +export const ESCAPE_PRIORITY = { + narrowOverlay: 10, + layoutEdit: 20, + zoneEditor: 30, + overlay: 40, + // An in-flight pane drag: Esc means "abort the drag", never ALSO exit edit + // mode / close the overlay the drag started over. Registered only for the + // drag's few-hundred-ms lifetime (drag-session.ts). + drag: 50 +} as const + +const active = new Map<symbol, number>() + +/** Register a layer as open; call the returned disposer when it closes. */ +export function pushEscapeLayer(priority: number): () => void { + const key = Symbol('escape-layer') + active.set(key, priority) + + return () => { + active.delete(key) + } +} + +/** True when no open layer outranks `priority`, so its handler should act. */ +export function isTopEscapeLayer(priority: number): boolean { + for (const p of active.values()) { + if (p > priority) { + return false + } + } + + return true +} diff --git a/apps/desktop/src/lib/gateway-events.test.ts b/apps/desktop/src/lib/gateway-events.test.ts index d51a943611f0..7435d22d6ee4 100644 --- a/apps/desktop/src/lib/gateway-events.test.ts +++ b/apps/desktop/src/lib/gateway-events.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { gatewayEventRequiresSessionId } from './gateway-events' +import { gatewayEventRequiresSessionId, resolveGatewayEventSessionId } from './gateway-events' describe('gateway event routing', () => { it('drops only unscoped subagent events (genuinely background work)', () => { @@ -24,4 +24,75 @@ describe('gateway event routing', () => { expect(gatewayEventRequiresSessionId('session.info')).toBe(false) expect(gatewayEventRequiresSessionId(undefined)).toBe(false) }) + + it('keeps unscoped stream events pinned to the session that started them', () => { + const started = resolveGatewayEventSessionId({ + activeSessionId: 'session-a', + eventType: 'message.start', + explicitSessionId: '', + unscopedStreamSessionId: null + }) + + expect(started).toEqual({ + drop: false, + nextUnscopedStreamSessionId: 'session-a', + sessionId: 'session-a' + }) + + const delta = resolveGatewayEventSessionId({ + activeSessionId: 'session-b', + eventType: 'message.delta', + explicitSessionId: '', + unscopedStreamSessionId: started.nextUnscopedStreamSessionId + }) + + expect(delta).toEqual({ + drop: false, + nextUnscopedStreamSessionId: 'session-a', + sessionId: 'session-a' + }) + + const completed = resolveGatewayEventSessionId({ + activeSessionId: 'session-b', + eventType: 'message.complete', + explicitSessionId: '', + unscopedStreamSessionId: delta.nextUnscopedStreamSessionId + }) + + expect(completed).toEqual({ + drop: false, + nextUnscopedStreamSessionId: null, + sessionId: 'session-a' + }) + }) + + it('routes a new unscoped stream start to the currently active session', () => { + const routed = resolveGatewayEventSessionId({ + activeSessionId: 'session-b', + eventType: 'message.start', + explicitSessionId: '', + unscopedStreamSessionId: 'session-a' + }) + + expect(routed).toEqual({ + drop: false, + nextUnscopedStreamSessionId: 'session-b', + sessionId: 'session-b' + }) + }) + + it('keeps explicit events scoped and clears a matching pinned stream on completion', () => { + const routed = resolveGatewayEventSessionId({ + activeSessionId: 'session-b', + eventType: 'message.complete', + explicitSessionId: 'session-a', + unscopedStreamSessionId: 'session-a' + }) + + expect(routed).toEqual({ + drop: false, + nextUnscopedStreamSessionId: null, + sessionId: 'session-a' + }) + }) }) diff --git a/apps/desktop/src/lib/gateway-events.ts b/apps/desktop/src/lib/gateway-events.ts index 673d1df8c6d6..7871848c091f 100644 --- a/apps/desktop/src/lib/gateway-events.ts +++ b/apps/desktop/src/lib/gateway-events.ts @@ -11,6 +11,34 @@ function asRecord(payload: unknown): Record<string, unknown> { return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {} } +/** + * Unscoped stream events that must stay pinned to the session that received + * ``message.start`` after the user switches chats mid-turn (#47709 / #48281). + * Without this, ``explicitSid || activeSessionId`` reattributes live deltas to + * the newly focused chat. + */ +const UNSCOPED_STREAM_EVENT_TYPES = new Set([ + 'approval.request', + 'browser.progress', + 'clarify.request', + 'error', + 'message.complete', + 'message.delta', + 'message.start', + 'reasoning.available', + 'reasoning.delta', + 'secret.request', + 'status.update', + 'sudo.request', + 'thinking.delta', + 'tool.complete', + 'tool.generating', + 'tool.progress', + 'tool.start' +]) + +const UNSCOPED_STREAM_END_EVENT_TYPES = new Set(['error', 'message.complete']) + /** * Whether an unscoped event (no `session_id`) must be dropped rather than * attributed to the focused chat. @@ -27,6 +55,71 @@ export function gatewayEventRequiresSessionId(eventType: string | undefined): bo return eventType?.startsWith('subagent.') ?? false } +export interface GatewayEventSessionRouteInput { + activeSessionId: null | string + eventType: string | undefined + explicitSessionId: string + unscopedStreamSessionId: null | string +} + +export interface GatewayEventSessionRoute { + drop: boolean + nextUnscopedStreamSessionId: null | string + sessionId: null | string +} + +/** + * Resolve which runtime session owns a gateway event. + * + * Explicit ``session_id`` always wins. Unscoped stream events pin to the + * session that received ``message.start`` so a mid-turn chat switch cannot + * steal live deltas / tool events onto the newly focused transcript. + */ +export function resolveGatewayEventSessionId({ + activeSessionId, + eventType, + explicitSessionId, + unscopedStreamSessionId +}: GatewayEventSessionRouteInput): GatewayEventSessionRoute { + if (explicitSessionId) { + const nextUnscopedStreamSessionId = + eventType && UNSCOPED_STREAM_END_EVENT_TYPES.has(eventType) && explicitSessionId === unscopedStreamSessionId + ? null + : unscopedStreamSessionId + + return { + drop: false, + nextUnscopedStreamSessionId, + sessionId: explicitSessionId + } + } + + if (gatewayEventRequiresSessionId(eventType)) { + return { + drop: true, + nextUnscopedStreamSessionId: unscopedStreamSessionId, + sessionId: null + } + } + + const streamEvent = eventType ? UNSCOPED_STREAM_EVENT_TYPES.has(eventType) : false + const sessionId = + eventType === 'message.start' ? activeSessionId : streamEvent ? unscopedStreamSessionId || activeSessionId : activeSessionId + let nextUnscopedStreamSessionId = unscopedStreamSessionId + + if (eventType === 'message.start' && activeSessionId) { + nextUnscopedStreamSessionId = activeSessionId + } else if (eventType && UNSCOPED_STREAM_END_EVENT_TYPES.has(eventType)) { + nextUnscopedStreamSessionId = null + } + + return { + drop: false, + nextUnscopedStreamSessionId, + sessionId + } +} + export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean { if (event.type !== 'tool.complete') { return false diff --git a/apps/desktop/src/lib/gateway-rpc.test.ts b/apps/desktop/src/lib/gateway-rpc.test.ts index 6da30b87b765..6c84c12ecaa8 100644 --- a/apps/desktop/src/lib/gateway-rpc.test.ts +++ b/apps/desktop/src/lib/gateway-rpc.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { isMissingRpcMethod } from './gateway-rpc' +import { isMissingPendingPromptRequest, isMissingRpcMethod } from './gateway-rpc' describe('isMissingRpcMethod', () => { it('detects JSON-RPC method-not-found errors', () => { @@ -14,3 +14,15 @@ describe('isMissingRpcMethod', () => { expect(isMissingRpcMethod(new Error('no such project'))).toBe(false) }) }) + +describe('isMissingPendingPromptRequest', () => { + it('detects stale prompt response errors from the gateway', () => { + expect(isMissingPendingPromptRequest(new Error('no pending password request'), 'password')).toBe(true) + expect(isMissingPendingPromptRequest(new Error('RPC failed: no pending value request'), 'value')).toBe(true) + }) + + it('ignores unrelated gateway failures', () => { + expect(isMissingPendingPromptRequest(new Error('gateway not connected'), 'password')).toBe(false) + expect(isMissingPendingPromptRequest(new Error('no pending value request'), 'password')).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/gateway-rpc.ts b/apps/desktop/src/lib/gateway-rpc.ts index a209aefbd001..6cf298402f32 100644 --- a/apps/desktop/src/lib/gateway-rpc.ts +++ b/apps/desktop/src/lib/gateway-rpc.ts @@ -4,3 +4,10 @@ export function isMissingRpcMethod(error: unknown): boolean { return /method not found|-32601|unknown method|no such method/i.test(message) } + +/** True when a prompt response raced a backend-side timeout / completion. */ +export function isMissingPendingPromptRequest(error: unknown, key: string): boolean { + const message = error instanceof Error ? error.message : String(error) + + return message.toLowerCase().includes(`no pending ${key.toLowerCase()} request`) +} diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index e863aa392804..df12f7f59097 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -25,8 +25,10 @@ import { IconChevronRight as ChevronRight, IconChevronRight as ChevronRightIcon, IconCircle as CircleIcon, + IconCircleLetterA as CircleLetterA, IconClipboard as Clipboard, IconClock as Clock, + IconCloud as Cloud, IconCommand as Command, IconCopy as Copy, IconCopy as CopyIcon, @@ -140,8 +142,10 @@ export { ChevronRight, ChevronRightIcon, CircleIcon, + CircleLetterA, Clipboard, Clock, + Cloud, Command, Copy, CopyIcon, diff --git a/apps/desktop/src/lib/incremental-external-store-runtime.ts b/apps/desktop/src/lib/incremental-external-store-runtime.ts index c055175091dd..a5b8d3fa7599 100644 --- a/apps/desktop/src/lib/incremental-external-store-runtime.ts +++ b/apps/desktop/src/lib/incremental-external-store-runtime.ts @@ -8,6 +8,8 @@ import { import { type AssistantRuntime, type ExternalStoreAdapter, + fromThreadMessageLike, + generateId, type ThreadMessage, useRuntimeAdapters } from '@assistant-ui/react' @@ -39,6 +41,17 @@ function syncRepositoryIncrementally( ): readonly ThreadMessage[] { const repository = (runtime as unknown as { repository: ExternalStoreThreadRuntimeCore['repository'] }).repository const incomingIds = new Set(messageRepository.messages.map(({ message }) => message.id)) + const existing = repository.export().messages + + // A thread switch swaps in a fully-DISJOINT transcript (no id carries over). + // Reconciling two unrelated trees in place — grafting the new chain onto the + // old one, then pruning — can strand a stale head/branch, so there's nothing + // to preserve: clear the tree first (leaves→root), then rebuild clean. + if (existing.length > 0 && !existing.some(({ message }) => incomingIds.has(message.id))) { + for (const { message } of [...existing].reverse()) { + repository.deleteMessage(message.id) + } + } for (const { message, parentId } of messageRepository.messages) { repository.addOrUpdateMessage(parentId, message) @@ -134,11 +147,19 @@ class IncrementalExternalStoreThreadRuntimeCore extends ExternalStoreThreadRunti self._notifyEventSubscribers(store.isRunning ? 'runStart' : 'runEnd', {}) } + // metadata.isOptimistic keeps this placeholder ephemeral: core evicts + // off-branch optimistic messages on head moves and omits them from export(). if (hasUpcomingMessage(isRunning, messages)) { - self._assistantOptimisticId = this.repository.appendOptimisticMessage(messages.at(-1)?.id ?? null, { - role: 'assistant', - content: [] - }) + const optimisticId = generateId() + this.repository.addOrUpdateMessage( + messages.at(-1)?.id ?? null, + fromThreadMessageLike( + { role: 'assistant', content: [], metadata: { isOptimistic: true } }, + optimisticId, + { type: 'running' } + ) + ) + self._assistantOptimisticId = optimisticId } this.repository.resetHead(self._assistantOptimisticId ?? messages.at(-1)?.id ?? null) diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 913b7c96a8f7..67a57b089bfc 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -5,6 +5,8 @@ // like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To // add a hotkey, add a row here and a handler there — nothing else. +import { registry } from '@/contrib/registry' + import { IS_MAC } from './combo' export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view' @@ -22,6 +24,8 @@ export interface KeybindActionMeta { category: KeybindCategory /** Default combos. Empty = shipped unbound (user can assign one). */ defaults: readonly string[] + /** Display label for CONTRIBUTED actions (built-ins use i18n). */ + label?: string } // Positional switch slots for *named* profiles: ⌘1…⌘9 for profiles 1-9, then @@ -69,6 +73,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ // ── Session ────────────────────────────────────────────────────────────── { id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] }, + { id: 'session.newTab', category: 'session', defaults: ['mod+t'] }, { id: 'session.newWindow', category: 'session', defaults: ['mod+shift+n'] }, // ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd // (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts. @@ -111,6 +116,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ { id: 'view.closeTerminal', category: 'view', defaults: ['ctrl+shift+w'] }, // ⌘\ — the backslash reads like a mirror line flipping the layout. { id: 'view.flipPanes', category: 'view', defaults: ['mod+\\'] }, + // ⌘W closes the focused zone's active tab — its own tab strip (preview) or + // the tree tab (session tiles, files, terminal). The uncloseable workspace + // is a no-op. ⌘⇧T reopens the last closed tab where it was. + { id: 'view.closeTab', category: 'view', defaults: ['mod+w'] }, + { id: 'view.reopenTab', category: 'view', defaults: ['mod+shift+t'] }, { id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] }, { id: 'keybinds.openPanel', category: 'view', defaults: ['mod+/'] } ] @@ -119,14 +129,58 @@ export const KEYBIND_ACTION_IDS: readonly string[] = KEYBIND_ACTIONS.map(action const ACTION_BY_ID = new Map(KEYBIND_ACTIONS.map(action => [action.id, action])) +// ── Contributed actions — the `keybinds` registry area ────────────────────── +// Same declarative schema as every other surface: a data contribution carries +// the action's metadata AND its handler. Contributed actions are first-class: +// they dispatch, appear in the panel, are rebindable, and their overrides +// persist exactly like built-ins. Built-in ids can't be shadowed. + +export const KEYBINDS_AREA = 'keybinds' + +/** Payload of a `keybinds` data contribution. */ +export interface KeybindContribution { + id: string + /** Panel section. Defaults to `view`. */ + category?: KeybindCategory + /** Default combos (canonical form, e.g. `mod+shift+\\`). Empty = unbound. */ + defaults?: readonly string[] + label: string + run: () => void +} + +export function contributedKeybinds(): KeybindContribution[] { + return registry + .getArea(KEYBINDS_AREA) + .map(c => c.data as KeybindContribution) + .filter(k => Boolean(k?.id && k.label) && typeof k?.run === 'function' && !ACTION_BY_ID.has(k.id)) +} + +/** Built-ins + contributed, one metadata list (panel, bindings, conflicts). */ +export function allKeybindActions(): KeybindActionMeta[] { + return [ + ...KEYBIND_ACTIONS, + ...contributedKeybinds().map(k => ({ + id: k.id, + category: k.category ?? ('view' as const), + defaults: k.defaults ?? [], + label: k.label + })) + ] +} + export function keybindAction(id: string): KeybindActionMeta | undefined { - return ACTION_BY_ID.get(id) + return ACTION_BY_ID.get(id) ?? allKeybindActions().find(action => action.id === id) +} + +/** The contributed handler for an action id (built-ins wire theirs in use-keybinds). */ +export function contributedKeybindHandler(id: string): (() => void) | undefined { + return contributedKeybinds().find(k => k.id === id)?.run } export type KeybindBindings = Record<string, string[]> export function defaultBindings(): KeybindBindings { - return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]])) + return Object.fromEntries(allKeybindActions().map(action => [action.id, [...action.defaults]])) } // Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is @@ -150,6 +204,5 @@ export const KEYBIND_READONLY: readonly KeybindReadonly[] = [ { id: 'composer.history', category: 'composer', keys: ['up', 'down'] }, { id: 'composer.cancel', category: 'composer', keys: ['escape'] }, // Fixed, context-local shortcuts surfaced for discoverability. - { id: 'view.terminalSelection', category: 'view', keys: ['mod+l'] }, - { id: 'view.closePreviewTab', category: 'view', keys: ['mod+w'] } + { id: 'view.terminalSelection', category: 'view', keys: ['mod+l'] } ] diff --git a/apps/desktop/src/lib/markdown-preprocess.ts b/apps/desktop/src/lib/markdown-preprocess.ts index 5fd08453b26e..63768a710d14 100644 --- a/apps/desktop/src/lib/markdown-preprocess.ts +++ b/apps/desktop/src/lib/markdown-preprocess.ts @@ -1,3 +1,5 @@ +import { escapeCurrencyDollars, normalizeMathDelimiters } from '@assistant-ui/react-streamdown' + import { isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code' import { stripPreviewTargets } from '@/lib/preview-targets' @@ -310,41 +312,6 @@ function normalizeFenceBlocks(text: string): string { return out.join('\n') } -// Convert LaTeX bracket delimiters to remark-math's dollar-sign syntax. -// Models often emit `\(...\)` for inline math and `\[...\]` for display -// math (the standard LaTeX convention) instead of `$...$` / `$$...$$`. -// remark-math only natively recognizes the dollar form, so we rewrite at -// preprocess time. Done with simple non-greedy matches keyed on the -// escaped-bracket sequences — these are rare enough in non-math content -// (you'd have to write a literal `\(` followed eventually by a literal -// `\)` with NO interleaving newline-paragraph-break) that false positives -// are extremely unlikely. -const LATEX_INLINE_RE = /\\\(([^\n]+?)\\\)/g -const LATEX_DISPLAY_RE = /\\\[([\s\S]+?)\\\]/g - -function rewriteLatexBracketDelimiters(text: string): string { - return text - .replace(LATEX_INLINE_RE, (_, body: string) => `$${body}$`) - .replace(LATEX_DISPLAY_RE, (_, body: string) => `$$${body}$$`) -} - -// Escape `$<digit>` patterns so they don't get eaten as math delimiters. -// Models commonly write currency amounts ($5, $19.99, $1,299) in prose. -// With `singleDollarTextMath: true`, remark-math is greedy and matches -// EVERY pair of `$`s — including the open of `$5` to the next `$10`, -// rendering "5 in my pocket and you have " as italicized math text. -// The de-facto convention across math-supporting LLM UIs is to treat -// `$` followed by a digit as currency rather than math, since math -// expressions almost always start with a letter or `\command`. Trade- -// off: a math expression like `$5x = 10$` would have its leading 5 -// escaped — annoying but rare. The escape `\$` survives to render as -// a literal `$` in the final output. -const CURRENCY_DOLLAR_RE = /(^|[^\\])\$(?=\d)/g - -function escapeCurrencyDollars(text: string): string { - return text.replace(CURRENCY_DOLLAR_RE, '$1\\$') -} - export function preprocessMarkdown(text: string): string { const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '') const scrubbed = scrubBacktickNoise(cleaned) @@ -377,12 +344,10 @@ export function preprocessMarkdown(text: string): string { const leading = part.match(/^\s*/)?.[0] ?? '' const trailing = part.match(/\s*$/)?.[0] ?? '' - // rewriteLatexBracketDelimiters runs only on prose segments so - // we don't accidentally touch `\(` inside a code block. - // escapeCurrencyDollars likewise only runs on prose, so legit - // `$5` literals inside fenced code stay intact. + // Run only on prose segments so `$5` literals and `\(` inside code + // blocks stay intact. const transformed = normalizeVisibleProse( - stripPreviewTargets(rewriteLatexBracketDelimiters(escapeCurrencyDollars(part))) + stripPreviewTargets(normalizeMathDelimiters(escapeCurrencyDollars(part))) ) return leading + transformed + trailing diff --git a/apps/desktop/src/lib/media.ts b/apps/desktop/src/lib/media.ts index e8dfd35c7f6e..ffaea2c7c2bc 100644 --- a/apps/desktop/src/lib/media.ts +++ b/apps/desktop/src/lib/media.ts @@ -79,7 +79,7 @@ export function mediaExternalUrl(path: string): string { return /^file:/i.test(path) ? path : `file://${path}` } -// Custom Electron scheme (registered in electron/main.cjs) that streams a local +// Custom Electron scheme (registered in electron/main.ts) that streams a local // file with Range support. Used for audio/video so playback bypasses the data // URL size cap and supports seeking. `path` may be a plain path or `file://…`. export function mediaStreamUrl(path: string): string { diff --git a/apps/desktop/src/lib/model-options.test.ts b/apps/desktop/src/lib/model-options.test.ts index a1f6c057ed9d..65bc180a7f8f 100644 --- a/apps/desktop/src/lib/model-options.test.ts +++ b/apps/desktop/src/lib/model-options.test.ts @@ -24,7 +24,7 @@ describe('requestModelOptions', () => { await expect(requestModelOptions({ gateway: gateway as never, sessionId: null })).resolves.toBe(gatewayPayload) - expect(gateway.request).toHaveBeenCalledWith('model.options', {}) + expect(gateway.request).toHaveBeenCalledWith('model.options', { explicit_only: true }) expect(getGlobalModelOptions).not.toHaveBeenCalled() }) @@ -36,6 +36,7 @@ describe('requestModelOptions', () => { await requestModelOptions({ gateway: gateway as never, refresh: true, sessionId: 'session-1' }) expect(gateway.request).toHaveBeenCalledWith('model.options', { + explicit_only: true, refresh: true, session_id: 'session-1' }) @@ -44,6 +45,6 @@ describe('requestModelOptions', () => { it('falls back to REST when no gateway is connected', async () => { await requestModelOptions({ refresh: true }) - expect(getGlobalModelOptions).toHaveBeenCalledWith({ refresh: true }) + expect(getGlobalModelOptions).toHaveBeenCalledWith({ explicitOnly: true, refresh: true }) }) }) diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts index 4e22d9fb9746..cc499c86d565 100644 --- a/apps/desktop/src/lib/model-status-label.test.ts +++ b/apps/desktop/src/lib/model-status-label.test.ts @@ -22,7 +22,9 @@ describe('model-status-label', () => { it('maps reasoning effort to compact labels', () => { expect(reasoningEffortLabel('high')).toBe('High') - expect(reasoningEffortLabel('xhigh')).toBe('Max') + expect(reasoningEffortLabel('xhigh')).toBe('XHigh') + expect(reasoningEffortLabel('max')).toBe('Max') + expect(reasoningEffortLabel('ultra')).toBe('Ultra') expect(reasoningEffortLabel('')).toBe('') }) diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts index 27a4d202b7d1..a1b28b8a4c58 100644 --- a/apps/desktop/src/lib/model-status-label.ts +++ b/apps/desktop/src/lib/model-status-label.ts @@ -6,7 +6,9 @@ const REASONING_LABELS: Record<string, string> = { low: 'Low', medium: 'Med', high: 'High', - xhigh: 'Max' + xhigh: 'XHigh', + max: 'Max', + ultra: 'Ultra' } export function reasoningEffortLabel(effort: string): string { diff --git a/apps/desktop/src/lib/remend-tail.test.ts b/apps/desktop/src/lib/remend-tail.test.ts deleted file mode 100644 index c730937356d5..000000000000 --- a/apps/desktop/src/lib/remend-tail.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown' -import remend from 'remend' -import { describe, expect, it } from 'vitest' - -import { findRemendWindowStart, tailBoundedRemend } from './remend-tail' - -const CORPUS = `# Heading one - -Intro paragraph with **bold**, *italic*, \`inline code\`, and a [link](https://example.com). - -## Code - -\`\`\`python -def main(): - cost = "$5" - print(f"total: $\{cost}") -\`\`\` - -Some text after the fence with $x^2 + y^2$ inline math. - -$$ -\\int_0^1 f(x) dx -$$ - -- list item one with **bold** -- list item two - -| col a | col b | -| ----- | ----- | -| 1 | 2 | - -~~~js -const s = \`template \${value}\` -~~~ - -Final paragraph with ~~strike~~ and unfinished [link text](https://exa -` - -/** - * Render-equivalence oracle: full-text remend and tail-bounded remend may - * differ in raw string output ONLY in ways that cannot affect rendering — - * i.e. after block splitting, every block must be identical. (Streamdown - * renders blocks independently, so block-level equality IS render equality.) - */ -function blocksOf(text: string): string[] { - return parseMarkdownIntoBlocks(text) -} - -describe('tailBoundedRemend', () => { - it('matches full remend block output at every streaming prefix', () => { - for (let end = 1; end <= CORPUS.length; end++) { - const prefix = CORPUS.slice(0, end) - const full = blocksOf(remend(prefix)) - const tail = blocksOf(tailBoundedRemend(prefix)) - - expect(tail, `prefix length ${end}: ${JSON.stringify(prefix.slice(-60))}`).toEqual(full) - } - }) - - it('repairs an unclosed fence opened early in a long message', () => { - const text = `intro\n\n\`\`\`python\n${'x = 1\n'.repeat(500)}print("$dollar")` - const repaired = tailBoundedRemend(text) - - expect(blocksOf(repaired)).toEqual(blocksOf(remend(text))) - // the window must reach back to the fence opener - expect(findRemendWindowStart(text)).toBe(text.indexOf('```python')) - }) - - it('bounds the window to the tail paragraph when no fence is open', () => { - const text = `para one\n\npara two\n\npara three with **bold` - const start = findRemendWindowStart(text) - - expect(start).toBe(text.indexOf('para three')) - expect(tailBoundedRemend(text)).toBe(remend(text)) - }) - - it('widens the window across an open $$ math block', () => { - const text = `before\n\n$$\n\\frac{a}{b}` - const start = findRemendWindowStart(text) - - expect(start).toBeLessThanOrEqual(text.indexOf('$$')) - expect(blocksOf(tailBoundedRemend(text))).toEqual(blocksOf(remend(text))) - }) - - it('handles closed constructs without modification', () => { - const text = `done **bold** and \`code\`\n\n\`\`\`js\nconst a = 1\n\`\`\`\n\nlast line.` - - expect(tailBoundedRemend(text)).toBe(text) - }) - - it('intentionally diverges from full remend on cross-block dangling openers', () => { - // Full remend scans the whole document and appends `**` for an opener - // left dangling in an EARLIER block, dumping stray asterisks into the - // unrelated tail block ("|**"). Because Streamdown splits into blocks - // after the repair, that opener never renders as bold either way — the - // tail-bounded result is the cleaner of the two. This test documents - // the divergence so a future remend upgrade that changes the behavior - // gets noticed. - const text = `- item with **dangling\n- item two\n\n|` - - expect(remend(text).endsWith('|**')).toBe(true) - expect(tailBoundedRemend(text).endsWith('|')).toBe(true) - expect(tailBoundedRemend(text).endsWith('|**')).toBe(false) - }) -}) diff --git a/apps/desktop/src/lib/remend-tail.ts b/apps/desktop/src/lib/remend-tail.ts deleted file mode 100644 index 683f7dc193e0..000000000000 --- a/apps/desktop/src/lib/remend-tail.ts +++ /dev/null @@ -1,108 +0,0 @@ -import remend from 'remend' - -// Tail-bounded incomplete-markdown repair. -// -// Streamdown's built-in `parseIncompleteMarkdown` runs `remend` over the whole -// accumulated message on every streaming flush (~18% of script time on 50KB+ -// messages). But repairs only ever matter in the trailing block: inline -// constructs can't cross a blank line, and Streamdown splits into blocks AFTER -// the repair, so a dangling opener in an earlier block can't reach the tail. -// We run `remend` on just that block instead. - -const BACKTICK = 96 // ` -const TILDE = 126 // ~ -const SPACE = 32 -const TAB = 9 -const BACKSLASH = 92 - -const isSpace = (c: number) => c === SPACE || c === TAB - -/** - * Index of the last top-level block start — the char after the most recent - * blank line that sits outside any open code fence or `$$` math block. An - * unclosed fence/math always begins after that blank, so it stays wholly - * inside the window without separate tracking. One cheap char pass, no regex. - */ -export function findRemendWindowStart(text: string): number { - const n = text.length - let inFence = false - let fenceChar = 0 - let fenceRun = 0 - let inMath = false - let boundary = 0 - let pending = -1 // a blank line, committed to `boundary` once content follows - - for (let lineStart = 0; lineStart <= n; ) { - let lineEnd = text.indexOf('\n', lineStart) - - if (lineEnd === -1) { - lineEnd = n - } - - let i = lineStart - - while (i < lineEnd && isSpace(text.charCodeAt(i))) { - i += 1 - } - - const first = i < lineEnd ? text.charCodeAt(i) : -1 - let marker = false - - // Fence open/close (``` or ~~~, ≤3 spaces indent). - if ((first === BACKTICK || first === TILDE) && i - lineStart <= 3) { - let run = i - - while (run < lineEnd && text.charCodeAt(run) === first) { - run += 1 - } - - if (run - i >= 3) { - marker = true - - if (!inFence) { - inFence = true - fenceChar = first - fenceRun = run - i - } else if (first === fenceChar && run - i >= fenceRun && onlyWhitespace(text, run, lineEnd)) { - inFence = false - } - } - } - - // Toggle `$$` math state on plain lines ($$ inside a fence is literal). - if (!inFence && !marker) { - for (let s = text.indexOf('$$', lineStart); s !== -1 && s < lineEnd - 1; s = text.indexOf('$$', s + 2)) { - if (s === 0 || text.charCodeAt(s - 1) !== BACKSLASH) { - inMath = !inMath - } - } - } - - if (first === -1 && !inFence && !inMath) { - pending = lineEnd + 1 - } else if (pending !== -1) { - boundary = pending - pending = -1 - } - - lineStart = lineEnd + 1 - } - - return boundary -} - -function onlyWhitespace(text: string, from: number, to: number): boolean { - for (let i = from; i < to; i += 1) { - if (!isSpace(text.charCodeAt(i))) { - return false - } - } - - return true -} - -export function tailBoundedRemend(text: string): string { - const start = findRemendWindowStart(text) - - return start <= 0 ? remend(text) : text.slice(0, start) + remend(text.slice(start)) -} diff --git a/apps/desktop/src/lib/reorder.ts b/apps/desktop/src/lib/reorder.ts new file mode 100644 index 000000000000..6aad349d85a2 --- /dev/null +++ b/apps/desktop/src/lib/reorder.ts @@ -0,0 +1,33 @@ +/** + * THE reorder feel — one primitive for every horizontal drag-to-reorder strip + * (profile rail squares, pane tab chips, and whatever comes next). Reorder + * surfaces must read identically: + * + * - the dragged item GLIDES BETWEEN SNAPPED SLOTS (it steps cell-to-cell on + * the snappier drag transition, never floats freely), + * - displaced neighbors spring aside on the slower rail transition, + * - both use the same easeOutBack overshoot, + * - a haptic tick marks each slot crossing, a success pulse the commit. + * + * Consumers differ in machinery (dnd-kit for the profile rail, the layout + * tree's pointer-capture drag for tabs) but share these exact parameters. + */ + +import { triggerHaptic } from '@/lib/haptics' + +/** easeOutBack — a little overshoot so items spring into their slot. */ +export const REORDER_SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)' + +/** Displaced neighbors reflow on this (dnd-kit object + CSS string forms). */ +export const REORDER_RAIL_DURATION_MS = 300 +export const REORDER_RAIL_TRANSITION = { duration: REORDER_RAIL_DURATION_MS, easing: REORDER_SPRING } +export const REORDER_RAIL_TRANSITION_CSS = `transform ${REORDER_RAIL_DURATION_MS}ms ${REORDER_SPRING}` + +/** The dragged item glides between snapped slots on this (snappier). */ +export const REORDER_DRAG_TRANSITION_CSS = `transform 200ms ${REORDER_SPRING}` + +/** Tick each time the drag crosses into a new slot. */ +export const reorderStepHaptic = () => triggerHaptic('selection') + +/** Satisfying confirm on a committed reorder. */ +export const reorderCommitHaptic = () => triggerHaptic('success') diff --git a/apps/desktop/src/lib/session-search.test.ts b/apps/desktop/src/lib/session-search.test.ts index 00027ff3186b..79384c9d5b12 100644 --- a/apps/desktop/src/lib/session-search.test.ts +++ b/apps/desktop/src/lib/session-search.test.ts @@ -52,6 +52,12 @@ describe('sessionMatchesSearch', () => { expect(sessionMatchesSearch(session, 'hermes-agent')).toBe(true) }) + it('matches sessions by git branch', () => { + expect(sessionMatchesSearch(makeSession({ git_branch: 'feat/cool-thing' }), 'feat/cool-thing')).toBe(true) + expect(sessionMatchesSearch(makeSession({ git_branch: 'feat/cool-thing' }), 'cool')).toBe(true) + expect(sessionMatchesSearch(makeSession({ git_branch: 'main' }), 'main')).toBe(true) + }) + it('matches sessions by source platform and aliases', () => { expect(sessionMatchesSearch(makeSession({ source: 'telegram' }), 'Telegram')).toBe(true) expect(sessionMatchesSearch(makeSession({ source: 'whatsapp' }), 'WhatsApp')).toBe(true) diff --git a/apps/desktop/src/lib/session-search.ts b/apps/desktop/src/lib/session-search.ts index 1b7f508a20b2..967c58816611 100644 --- a/apps/desktop/src/lib/session-search.ts +++ b/apps/desktop/src/lib/session-search.ts @@ -17,6 +17,7 @@ export function sessionMatchesSearch(session: SessionInfo, query: string): boole sessionTitle(session), session.preview ?? '', session.cwd ?? '', + session.git_branch ?? '', ...sessionSourceSearchTerms(session.source) ].some(value => value.toLowerCase().includes(needle)) } diff --git a/apps/desktop/src/app/desktop-controller-utils.test.ts b/apps/desktop/src/lib/session-signatures.test.ts similarity index 54% rename from apps/desktop/src/app/desktop-controller-utils.test.ts rename to apps/desktop/src/lib/session-signatures.test.ts index ac39395db09e..84b52a0cf548 100644 --- a/apps/desktop/src/app/desktop-controller-utils.test.ts +++ b/apps/desktop/src/lib/session-signatures.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { SessionInfo } from '@/hermes' -import { sameCronSignature } from './desktop-controller-utils' +import { sameCronSignature, sessionMessagesSignature } from './session-signatures' const session = (id: string, title: string | null): SessionInfo => ({ id, title }) as SessionInfo @@ -29,3 +29,20 @@ describe('sameCronSignature', () => { expect(sameCronSignature(a, b)).toBe(false) }) }) + +describe('sessionMessagesSignature', () => { + const msg = (role: string, content: string) => ({ role, content }) as Parameters<typeof sessionMessagesSignature>[0][number] + + it('is stable for identical transcripts', () => { + expect(sessionMessagesSignature([msg('user', 'hi')])).toBe(sessionMessagesSignature([msg('user', 'hi')])) + }) + + it('changes when content changes', () => { + expect(sessionMessagesSignature([msg('user', 'hi')])).not.toBe(sessionMessagesSignature([msg('user', 'yo')])) + }) + + it('changes when a message is appended', () => { + const one = [msg('user', 'hi')] + expect(sessionMessagesSignature(one)).not.toBe(sessionMessagesSignature([...one, msg('assistant', 'hey')])) + }) +}) diff --git a/apps/desktop/src/lib/session-signatures.ts b/apps/desktop/src/lib/session-signatures.ts new file mode 100644 index 000000000000..4ef20e1fab19 --- /dev/null +++ b/apps/desktop/src/lib/session-signatures.ts @@ -0,0 +1,54 @@ +/** + * Cheap signature compares for poll loops — swap the atom (and re-render) + * only when the rows/transcript actually changed. + */ + +import type { SessionInfo, SessionMessage } from '@/hermes' + +export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean { + if (a.length !== b.length) { + return false + } + + return a.every((session, i) => { + const other = b[i] + + return ( + other != null && + session.id === other.id && + session._lineage_root_id === other._lineage_root_id && + session.title === other.title && + session.source === other.source && + session.profile === other.profile && + session.preview === other.preview && + session.message_count === other.message_count && + session.last_active === other.last_active && + session.ended_at === other.ended_at + ) + }) +} + +// FNV-1a over role/timestamp/content. +function hashString(hash: number, value: string): number { + let next = hash + + for (let i = 0; i < value.length; i++) { + next ^= value.charCodeAt(i) + next = Math.imul(next, 16777619) + } + + return next >>> 0 +} + +/** Transcript fingerprint for the active-messaging-session poll. */ +export function sessionMessagesSignature(messages: SessionMessage[]): string { + let hash = 2166136261 + + for (const m of messages) { + hash = hashString(hash, m.role) + hash = hashString(hash, String(m.timestamp ?? '')) + hash = hashString(hash, typeof m.content === 'string' ? m.content : (JSON.stringify(m.content) ?? '')) + } + + return `${messages.length}:${hash}` +} diff --git a/apps/desktop/src/lib/storage.ts b/apps/desktop/src/lib/storage.ts index 6bc6ef9a814a..bc3eb13aa3b6 100644 --- a/apps/desktop/src/lib/storage.ts +++ b/apps/desktop/src/lib/storage.ts @@ -56,6 +56,27 @@ export function writeKey(key: string, value: null | string) { emitPersistence({ key, op: value === null ? 'remove' : 'write', value }) } +/** Parsed JSON read. Returns null on absence, unavailable storage, OR malformed + * JSON — callers layer their own shape validation on the parsed value. */ +export function readJson<T>(key: string): T | null { + const raw = readKey(key) + + if (raw === null) { + return null + } + + try { + return JSON.parse(raw) as T + } catch { + return null + } +} + +/** JSON write; a null value removes the key. Best-effort (see writeKey). */ +export function writeJson(key: string, value: unknown) { + writeKey(key, value === null ? null : JSON.stringify(value)) +} + export function storedBoolean(key: string, fallback: boolean): boolean { const value = readKey(key) diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 5b7621aa0153..159270c01664 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -17,18 +17,10 @@ import { ThemeProvider } from './themes/context' installClipboardShim() -// Dev-only: install __PERF_DRIVE__ + __PERF_PROBE__ on window so the -// scripts/ harnesses can drive a synthetic stream + record render cost. -// Tree-shaken out of production builds. (Uses MODE rather than DEV because -// our Vite setup currently bundles with PROD=true even in `vite dev`; see -// scripts/dev-no-hmr.mjs for the surrounding workarounds.) if (import.meta.env.MODE !== 'production') { import('./app/chat/perf-probe') } -// The pet overlay rides this same bundle (`?win=overlay`) but mounts a tiny, -// transparent, gateway-less surface instead of the full app. Branch before any -// app-shell work so the overlay window stays cheap. if (new URLSearchParams(window.location.search).get('win') === 'overlay') { void import('./app/pet-overlay/overlay-root').then(({ mountPetOverlay }) => mountPetOverlay()) } else { diff --git a/apps/desktop/src/plugins/README.md b/apps/desktop/src/plugins/README.md new file mode 100644 index 000000000000..adcad66e2307 --- /dev/null +++ b/apps/desktop/src/plugins/README.md @@ -0,0 +1,14 @@ +# Bundled plugins + +Drop a `<name>/plugin.{ts,tsx}` here that default-exports a `HermesPlugin` and +it registers automatically at boot (vite glob in `../contrib/plugins.ts`), with +the same inventory + live enable/disable contract as runtime plugins. + +None ship in-tree today — reference/demo plugins (the counter example, the +gateway-pill 1:1 rebuild, the runtime-loader hello world) live in the companion +[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins) +repo so the shipped app stays uncluttered. + +User- and agent-authored plugins load at runtime from +`$HERMES_HOME/desktop-plugins/<name>/plugin.js` (the disk door) — see the +`hermes-desktop-plugins` skill. diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts new file mode 100644 index 000000000000..c82ce4a05384 --- /dev/null +++ b/apps/desktop/src/sdk/index.ts @@ -0,0 +1,228 @@ +/** + * @hermes/plugin-sdk — THE plugin language. The vscode-module model: plugin + * authors import exactly one module and get everything — they never touch + * `@/…` internals (lint-fenced) and never need codebase access. + * + * Two delivery modes, one surface: + * - bundled (`src/plugins/<name>/`): the import resolves here via alias; + * - runtime-fetched (plugin host, next phase): the loader injects this same + * object as `window.__HERMES_PLUGIN_SDK__` and maps the import to it, so a + * published plugin builds against the types with the SDK marked external. + * + * Capability tiers (WoW-style): + * - `host.state.*` — READONLY app state (nanostore atoms; `.get()` or + * subscribe; `useValue` in React). + * - `host.*` actions — curated, safe verbs (toast, haptic). + * - `host.request` — the gateway JSON-RPC door; the plugin's real power, + * and the future seam for per-plugin capability grants. + * - `ui.*` — the design language, so plugin UI looks native by default. + */ + +import { atom, type ReadableAtom } from 'nanostores' + +import { $narrowViewport } from '@/components/pane-shell/tree/store' +import { onGatewayEvent } from '@/contrib/events' +import { getLogs, getStatus } from '@/hermes' +import { $gateway } from '@/store/gateway' +import { notify, notifyError } from '@/store/notifications' +import { $activeGatewayProfile } from '@/store/profile' +import { $activeSessionId, $currentCwd, $currentModel, $gatewayState } from '@/store/session' +import { runGatewayRestart } from '@/store/system-actions' + +// -- state: readonly views over the app's live atoms ------------------------- + +const readonlyAtom = <T>(atomLike: ReadableAtom<T>): ReadableAtom<T> => atomLike + +/** Window geometry + the app's responsive posture, one readonly rect. */ +export interface ViewportRect { + width: number + height: number + /** Below the app's sidebar-collapse breakpoint (rails become overlays). */ + narrow: boolean +} + +const readViewport = (): ViewportRect => ({ + width: typeof window === 'undefined' ? 0 : window.innerWidth, + height: typeof window === 'undefined' ? 0 : window.innerHeight, + narrow: $narrowViewport.get() +}) + +const $viewport = atom<ViewportRect>(readViewport()) + +if (typeof window !== 'undefined') { + const refresh = () => $viewport.set(readViewport()) + window.addEventListener('resize', refresh) + $narrowViewport.listen(refresh) +} + +export const host = { + state: { + /** Runtime id of the active chat session (null on a fresh draft). */ + activeSessionId: readonlyAtom<null | string>($activeSessionId), + /** Active workspace cwd ('' when detached). */ + cwd: readonlyAtom<string>($currentCwd), + /** Gateway socket state: 'idle' | 'connecting' | 'open' | …. */ + gateway: readonlyAtom<string>($gatewayState), + /** Current main model slug. */ + model: readonlyAtom<string>($currentModel), + /** Profile the live gateway is routed to. */ + profile: readonlyAtom<string>($activeGatewayProfile), + /** Window geometry ({ width, height, narrow }). */ + viewport: readonlyAtom<ViewportRect>($viewport) + }, + + /** Toast into the app's notification stack. */ + notify, + notifyError, + + // NOTE: every host door is async-safe — wrapped so a sync throw from an + // internal helper (e.g. no desktop bridge in a plain browser) becomes a + // rejection a plugin's .catch() sees, never an error-boundary crash. + + /** Tail an app log file (`agent` / `errors` / `gateway` / `gui` / …). */ + logs: async (...args: Parameters<typeof getLogs>) => getLogs(...args), + + /** Navigate the app router (hash routes, e.g. '/command-center?section=system'). */ + navigate: (path: string) => { + window.location.hash = path.startsWith('#') ? path : `#${path}` + }, + + /** HEAR the gateway stream (message deltas, session lifecycle, tool + * activity, …) by event type — `'*'` for everything. Returns a disposer. + * Listeners are isolated; a throw can't affect app dispatch. */ + onEvent: onGatewayEvent, + + /** Restart the backend gateway (progress surfaces in the core statusbar). */ + restartGateway: async () => runGatewayRestart(), + + /** One-shot system status snapshot (platforms, versions, …). */ + status: async () => getStatus(), + + /** Gateway JSON-RPC — sessions, config, skills, cron, kanban, everything + * the app itself uses. Lazy: resolves the LIVE socket per call. */ + request: async <T>(method: string, params: Record<string, unknown> = {}): Promise<T> => { + const gateway = $gateway.get() + + if (!gateway) { + throw new Error('Hermes gateway unavailable') + } + + return gateway.request<T>(method, params) + } +} + +// -- react bridge ------------------------------------------------------------- + +// Every contribution surface, plugin-reachable: register keybinds, palette +// commands, routes, themes, panes, composer extensions, and bar items with +// the same area ids + payload types core uses. +export { COMPOSER_AREAS, type ComposerAttachmentProvider, type ComposerMiddleware } from '@/app/chat/composer/contrib' + +// -- ui: the design language -------------------------------------------------- + +export { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib' +export { type RouteContribution, ROUTES_AREA, SIDEBAR_NAV_AREA, type SidebarNavContribution } from '@/app/routes' +export type { StatusbarItem } from '@/app/shell/statusbar-controls' + +export type { TitlebarTool } from '@/app/shell/titlebar-controls' +export { StatusDot, type StatusTone } from '@/components/status-dot' +export { Badge } from '@/components/ui/badge' +export { Button } from '@/components/ui/button' +export { Checkbox } from '@/components/ui/checkbox' +export { Codicon } from '@/components/ui/codicon' +export { ConfirmDialog } from '@/components/ui/confirm-dialog' +export { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +export { CopyButton } from '@/components/ui/copy-button' +export { DecodeText } from '@/components/ui/decode-text' +export { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +export { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +export { EmptyState } from '@/components/ui/empty-state' +export { ErrorState } from '@/components/ui/error-state' +export { GlyphSpinner } from '@/components/ui/glyph-spinner' +export { Input } from '@/components/ui/input' +export { Kbd, KbdGroup } from '@/components/ui/kbd' +/** The app's canonical loader (animated curves; `lemniscate-bloom` for long + * page loads) — the same one every core page uses. */ +export { Loader, type LoaderType } from '@/components/ui/loader' +export { LogView } from '@/components/ui/log-view' +export { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +export { ScrollArea } from '@/components/ui/scroll-area' +export { SearchField } from '@/components/ui/search-field' +export { SegmentedControl } from '@/components/ui/segmented-control' +export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +export { Separator } from '@/components/ui/separator' +export { Skeleton } from '@/components/ui/skeleton' +export { Switch } from '@/components/ui/switch' +export { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +export { Textarea } from '@/components/ui/textarea' +export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +export type { GatewayEventListener } from '@/contrib/events' +export type { HermesPlugin, PluginContext, PluginContribution, PluginRestOptions, PluginStorage } from '@/contrib/plugin' + +// -- contracts ---------------------------------------------------------------- + +/** Mount-scoped contribution: while the rendering component is mounted, its + * children render in the target area's slot; unmount disposes it. Use for + * page-owned chrome (a page's titlebar control leaves with the page) — + * `ctx.register` stays the door for permanent contributions. Namespace the + * id with your plugin slug (`kanban:board-switcher`). */ +export { Contribute, type ContributeProps } from '@/contrib/react/contribute' +export type { Contribution } from '@/contrib/types' +/** Localized copy — plugins reuse the app's strings (and stay translatable). */ +export { useI18n } from '@/i18n' +export { triggerHaptic as haptic } from '@/lib/haptics' +/** The app's lucide icon set (RefreshCw, LayoutDashboard, Activity, …). */ +export * as icons from '@/lib/icons' +export { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' +/** The app's deterministic identity color for a name (profiles, assignees, + * authors) + its translucent tag fill — so plugin-rendered identities read + * the same hue as everywhere else. */ +export { profileColor, profileColorSoft } from '@/lib/profile-color' +/** The shared client itself, for invalidation OUTSIDE React (e.g. a + * `ctx.socket` frame invalidating a query). Inside components keep using + * `useQueryClient`. */ +export { queryClient } from '@/lib/query-client' + +export const PANES_AREA = 'panes' +export const STATUSBAR_AREAS = { left: 'statusBar.left', right: 'statusBar.right' } as const +export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left', right: 'titleBar.right' } as const + +/** The app's own gateway-readiness evaluation (setup.status + + * setup.runtime_check, reconciled) — pass `host.request`. Don't hand-roll + * readiness from raw RPC shapes. */ +export { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' +/** Canonical time formatting — every timestamp/age string in the app comes + * from these (localized `Intl` under the hood). Don't hand-roll "Xm ago". */ +export { coarseElapsed, fmtDateTime, fmtDayTime, relativeTime } from '@/lib/time' +export { cn } from '@/lib/utils' +export { THEMES_AREA } from '@/themes/user-themes' +export type { RpcEvent, StatusResponse } from '@/types/hermes' +/** Subscribe a component to a `host.state` atom. */ +export { useStore as useValue } from '@nanostores/react' +/** The app's data-fetching layer. Plugins share the ONE QueryClient mounted at + * the app root, so their queries cache, dedupe, poll (`refetchInterval`), and + * invalidate exactly like core screens — no hand-rolled atoms or polls. */ +export { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +/** Plugin-local reactive state (share between a trigger and its panel, poll + * loops, cross-component signals) — the same primitive `host.state` uses. */ +export { atom, computed } from 'nanostores' diff --git a/apps/desktop/src/sdk/runtime.ts b/apps/desktop/src/sdk/runtime.ts new file mode 100644 index 000000000000..449b9060b05d --- /dev/null +++ b/apps/desktop/src/sdk/runtime.ts @@ -0,0 +1,54 @@ +/** + * Runtime SDK injection — the other half of the vscode-module model. Bundled + * plugins resolve `@hermes/plugin-sdk` through the vite alias; RUNTIME-loaded + * plugins (disk / fetched) import the same specifier and get the same object: + * the loader rewrites bare specifiers to shim modules that re-export the + * live namespaces installed here. React ships as the app's singletons — + * a second React instance would break hooks. + */ + +import * as React from 'react' +import * as jsxDevRuntime from 'react/jsx-dev-runtime' +import * as jsxRuntime from 'react/jsx-runtime' + +import * as sdk from './index' + +const GLOBALS = { + __HERMES_PLUGIN_SDK__: sdk, + __HERMES_REACT__: React, + __HERMES_REACT_JSX__: jsxRuntime, + __HERMES_REACT_JSX_DEV__: jsxDevRuntime +} as const + +export function installPluginSdk(): void { + Object.assign(globalThis, GLOBALS) +} + +/** Build a shim ESM blob that re-exports a global namespace's live members. + * Export names come from the namespace itself, so the list can't drift. */ +function shimUrl(globalKey: keyof typeof GLOBALS): string { + const names = Object.keys(GLOBALS[globalKey]).filter(name => name !== 'default' && /^[A-Za-z_$][\w$]*$/.test(name)) + + const source = + `const m = globalThis.${globalKey};\n` + + `export default m.default ?? m;\n` + + // Guard the destructuring: `export const { } = m` is a syntax error, so + // only emit it when the namespace actually has named exports. + (names.length ? `export const { ${names.join(', ')} } = m;\n` : '') + + return URL.createObjectURL(new Blob([source], { type: 'text/javascript' })) +} + +let cached: Record<string, string> | null = null + +/** Specifier -> shim URL map for the runtime loader (longest keys first). */ +export function sdkImportMap(): Record<string, string> { + cached ??= { + '@hermes/plugin-sdk': shimUrl('__HERMES_PLUGIN_SDK__'), + 'react/jsx-dev-runtime': shimUrl('__HERMES_REACT_JSX_DEV__'), + 'react/jsx-runtime': shimUrl('__HERMES_REACT_JSX__'), + react: shimUrl('__HERMES_REACT__') + } + + return cached +} diff --git a/apps/desktop/src/store/approval-mode.test.ts b/apps/desktop/src/store/approval-mode.test.ts new file mode 100644 index 000000000000..5a15f6c6ff3b --- /dev/null +++ b/apps/desktop/src/store/approval-mode.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + $approvalModes, + approvalModeForProfile, + reconcileApprovalModeForProfile, + setApprovalModeForProfile, + syncApprovalModeForProfile +} from './approval-mode' + +function deferred<T>() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + + const promise = new Promise<T>((res, rej) => { + resolve = res + reject = rej + }) + + return { promise, reject, resolve } +} + +describe('profile-scoped approval mode cache', () => { + beforeEach(() => $approvalModes.set({})) + + it('labels an unread profile Smart by default and adopts backend truth', async () => { + expect(approvalModeForProfile('default')).toBe('smart') + + const request = vi.fn(async () => ({ value: 'manual' })) + await syncApprovalModeForProfile(request, 'default') + + expect(request).toHaveBeenCalledWith('config.get', { key: 'approvals.mode' }) + expect(approvalModeForProfile('default')).toBe('manual') + }) + + it('keeps profile values isolated', async () => { + await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'manual' })), 'work') + await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'off' })), 'personal') + + expect(approvalModeForProfile('work')).toBe('manual') + expect(approvalModeForProfile('personal')).toBe('off') + expect(approvalModeForProfile('default')).toBe('smart') + }) + + it('rolls consecutive failed writes back to the last authoritative value', async () => { + await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'smart' })), 'default') + const first = deferred<{ value: string }>() + const second = deferred<{ value: string }>() + + const request = vi + .fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + + const staleWrite = setApprovalModeForProfile(request, 'default', 'manual') + const currentWrite = setApprovalModeForProfile(request, 'default', 'off') + expect(approvalModeForProfile('default')).toBe('off') + + first.reject(new Error('old failure')) + await expect(staleWrite).rejects.toThrow('old failure') + expect(approvalModeForProfile('default')).toBe('off') + + second.reject(new Error('current failure')) + await expect(currentWrite).rejects.toThrow('current failure') + expect(approvalModeForProfile('default')).toBe('smart') + }) + + it('lets a backend event supersede an optimistic write and its later failure', async () => { + const write = deferred<{ value: string }>() + const pending = setApprovalModeForProfile(vi.fn(() => write.promise), 'work', 'off') + + reconcileApprovalModeForProfile('work', 'smart') + expect(approvalModeForProfile('work')).toBe('smart') + + write.reject(new Error('late failure')) + await expect(pending).rejects.toThrow('late failure') + expect(approvalModeForProfile('work')).toBe('smart') + }) + + it('ignores a stale initial read after a newer write succeeds', async () => { + const read = deferred<{ value: string }>() + + const request = vi + .fn() + .mockImplementationOnce(() => read.promise) + .mockResolvedValueOnce({ value: 'off' }) + + const staleRead = syncApprovalModeForProfile(request, 'default') + await setApprovalModeForProfile(request, 'default', 'off') + read.resolve({ value: 'manual' }) + await staleRead + + expect(approvalModeForProfile('default')).toBe('off') + }) +}) diff --git a/apps/desktop/src/store/approval-mode.ts b/apps/desktop/src/store/approval-mode.ts new file mode 100644 index 000000000000..1000f208d95f --- /dev/null +++ b/apps/desktop/src/store/approval-mode.ts @@ -0,0 +1,100 @@ +import { atom } from 'nanostores' + +export type ApprovalMode = 'manual' | 'off' | 'smart' +export type ApprovalModeRequester = ( + method: string, + params?: Record<string, unknown> +) => Promise<unknown> + +const APPROVAL_MODES = new Set<ApprovalMode>(['manual', 'smart', 'off']) +const revisions = new Map<string, number>() +const confirmedModes = new Map<string, ApprovalMode>() + +export const $approvalModes = atom<Record<string, ApprovalMode>>({}) + +function profileKey(profile: string): string { + return profile.trim() || 'default' +} + +function nextRevision(profile: string): number { + const revision = (revisions.get(profile) ?? 0) + 1 + revisions.set(profile, revision) + + return revision +} + +function normalizeApprovalMode(value: unknown): ApprovalMode { + const normalized = String(value ?? '') + .trim() + .toLowerCase() as ApprovalMode + + return APPROVAL_MODES.has(normalized) ? normalized : 'manual' +} + +export function approvalModeForProfile(profile: string): ApprovalMode { + return $approvalModes.get()[profileKey(profile)] ?? 'smart' +} + +function cacheApprovalMode(profile: string, mode: ApprovalMode): void { + const key = profileKey(profile) + $approvalModes.set({ ...$approvalModes.get(), [key]: mode }) +} + +export function reconcileApprovalModeForProfile(profile: string, value: unknown): ApprovalMode { + const key = profileKey(profile) + const mode = normalizeApprovalMode(value) + nextRevision(key) + confirmedModes.set(key, mode) + cacheApprovalMode(key, mode) + + return mode +} + +export async function syncApprovalModeForProfile( + requestGateway: ApprovalModeRequester, + profile: string +): Promise<ApprovalMode> { + const key = profileKey(profile) + const revision = nextRevision(key) + const result = (await requestGateway('config.get', { key: 'approvals.mode' })) as { value?: string } + const mode = normalizeApprovalMode(result?.value) + + if (revisions.get(key) === revision) { + confirmedModes.set(key, mode) + cacheApprovalMode(key, mode) + } + + return mode +} + +export async function setApprovalModeForProfile( + requestGateway: ApprovalModeRequester, + profile: string, + mode: ApprovalMode +): Promise<ApprovalMode> { + const key = profileKey(profile) + const revision = nextRevision(key) + cacheApprovalMode(key, mode) + + try { + const result = (await requestGateway('config.set', { + key: 'approvals.mode', + value: mode + })) as { value?: string } + + const authoritative = normalizeApprovalMode(result?.value) + + if (revisions.get(key) === revision) { + confirmedModes.set(key, authoritative) + cacheApprovalMode(key, authoritative) + } + + return authoritative + } catch (error) { + if (revisions.get(key) === revision) { + cacheApprovalMode(key, confirmedModes.get(key) ?? 'smart') + } + + throw error + } +} diff --git a/apps/desktop/src/store/backdrop.ts b/apps/desktop/src/store/backdrop.ts new file mode 100644 index 000000000000..cecc718026fe --- /dev/null +++ b/apps/desktop/src/store/backdrop.ts @@ -0,0 +1,14 @@ +import { atom } from 'nanostores' + +import { persistBoolean, storedBoolean } from '@/lib/storage' + +const KEY = 'hermes.desktop.backdrop.v1' + +/** Whether the faint statue image renders behind the chat transcript. */ +export const $backdrop = atom(storedBoolean(KEY, true)) + +$backdrop.subscribe(on => persistBoolean(KEY, on)) + +export function setBackdrop(on: boolean) { + $backdrop.set(on) +} diff --git a/apps/desktop/src/store/clarify.ts b/apps/desktop/src/store/clarify.ts index 14a98f15c88d..59449a946887 100644 --- a/apps/desktop/src/store/clarify.ts +++ b/apps/desktop/src/store/clarify.ts @@ -26,6 +26,11 @@ export const $clarifyRequest = computed( (requests, activeId) => requests[keyFor(activeId)] ?? null ) +/** The clarify request for one specific session — the tile counterpart of the + * active-session `$clarifyRequest` view (same map, fixed key). */ +export const sessionClarifyRequest = (sessionId: string | null) => + computed($clarifyRequests, requests => requests[keyFor(sessionId)] ?? null) + export function setClarifyRequest(request: ClarifyRequest): void { $clarifyRequests.set({ ...$clarifyRequests.get(), [keyFor(request.sessionId)]: request }) } diff --git a/apps/desktop/src/store/composer-status.ts b/apps/desktop/src/store/composer-status.ts index 4d20b476b747..5d5df96cb39f 100644 --- a/apps/desktop/src/store/composer-status.ts +++ b/apps/desktop/src/store/composer-status.ts @@ -6,6 +6,7 @@ import type { TodoItem, TodoStatus } from '@/lib/todos' import { $gateway } from './gateway' import { dispatchNativeNotification } from './native-notifications' import { notifyError } from './notifications' +import { $sessionStates } from './session-states' import { $subagentsBySession, type SubagentProgress } from './subagents' import { $todosBySession } from './todos' @@ -35,6 +36,35 @@ export interface ComposerStatusItem { // registry (`terminal(background=true)` spawns) via `process.list`. export const $backgroundStatusBySession = atom<Record<string, ComposerStatusItem[]>>({}) +// Stored session ids that have at least one RUNNING background process. The +// sidebar row reads this for a pulsing gray dot — distinct from the accent +// pulse of an active LLM turn — so the user can tell at a glance "this session +// has something chugging along in the background" even when the turn is idle. +// +// $backgroundStatusBySession is keyed by RUNTIME session id (gateway events +// and process.list both speak that); the sidebar row knows only the STORED id. +// $sessionStates bridges the two: runtime id → state.storedSessionId. +export const $backgroundRunningSessionIds = computed( + [$backgroundStatusBySession, $sessionStates], + (bg, states) => { + const ids = new Set<string>() + + for (const [runtimeId, items] of Object.entries(bg)) { + if (!items.some(i => i.state === 'running')) { + continue + } + + const storedId = states[runtimeId]?.storedSessionId + + if (storedId) { + ids.add(storedId) + } + } + + return [...ids] + } +) + // Rows the user X-ed away. The registry keeps finished processes around for a // while, so without this every refresh would resurrect a dismissed row. const dismissedBySession = new Map<string, Set<string>>() diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index c40cf867735b..514c0323a889 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -21,6 +21,80 @@ export const $composerDraft = atom('') export const $composerAttachments = atom<ComposerAttachment[]>([]) export const $composerTerminalSelections = atom<Record<string, string>>({}) +// --------------------------------------------------------------------------- +// Composer scopes — one live attachment set PER MOUNTED COMPOSER. The main +// chat's scope wraps the module-level atom above (all existing readers keep +// working); each session tile creates its own so two composers on screen +// never share chips. Draft text needs no scope: it lives in each ChatBar's +// DOM + draftRef and stashes per session key already. +// --------------------------------------------------------------------------- + +export interface ComposerAttachmentScope { + $attachments: ReturnType<typeof atom<ComposerAttachment[]>> + add(attachment: ComposerAttachment): void + clear(): void + remove(id: string): ComposerAttachment | null + setUploadState(id: string, uploadState?: ComposerAttachment['uploadState']): void + update(attachment: ComposerAttachment): boolean +} + +export function createComposerAttachmentScope( + $attachments = atom<ComposerAttachment[]>([]) +): ComposerAttachmentScope { + return { + $attachments, + add(attachment) { + const previous = $attachments.get() + const next = upsertAttachment(previous, attachment) + $attachments.set(next) + + if (next.length > previous.length && attachment.kind !== 'url') { + triggerHaptic('selection') + } + }, + clear() { + $attachments.set([]) + }, + remove(id) { + const current = $attachments.get() + const removed = current.find(attachment => attachment.id === id) || null + $attachments.set(current.filter(attachment => attachment.id !== id)) + + return removed + }, + setUploadState(id, uploadState) { + const current = $attachments.get() + const index = current.findIndex(attachment => attachment.id === id) + + if (index < 0) { + return + } + + const next = [...current] + next[index] = { ...next[index]!, uploadState } + $attachments.set(next) + }, + update(attachment) { + const current = $attachments.get() + const index = current.findIndex(item => item.id === attachment.id) + + if (index < 0) { + return false + } + + const next = [...current] + next[index] = attachment + $attachments.set(next) + + return true + } + } +} + +/** The main chat's scope — the module-level atom, so every existing + * `$composerAttachments` reader/writer IS this scope. */ +export const mainComposerScope = createComposerAttachmentScope($composerAttachments) + // Per-thread draft stash for the decoupled composer. Session lifecycle never // touches this — only ChatBar's scope swap reads/writes it. Text mirrors to // localStorage; attachments are memory-only (blobs, upload state). @@ -133,61 +207,22 @@ export function clearComposerDraft() { $composerDraft.set('') } -export function addComposerAttachment(attachment: ComposerAttachment) { - const previous = $composerAttachments.get() - const next = upsertAttachment(previous, attachment) - $composerAttachments.set(next) - - if (next.length > previous.length && attachment.kind !== 'url') { - triggerHaptic('selection') - } -} - -export function removeComposerAttachment(id: string): ComposerAttachment | null { - const current = $composerAttachments.get() - const removed = current.find(attachment => attachment.id === id) || null - $composerAttachments.set(current.filter(attachment => attachment.id !== id)) - - return removed -} +// Main-scope conveniences — the names the app has always used. +export const addComposerAttachment = (attachment: ComposerAttachment) => mainComposerScope.add(attachment) +export const removeComposerAttachment = (id: string) => mainComposerScope.remove(id) /** Replace an existing attachment in place by id. No-op (returns false) when the * id is gone — e.g. the user removed the chip while an eager upload was still in * flight, so a late success must NOT resurrect it. Use this instead of * addComposerAttachment for async results that may land after a removal. */ -export function updateComposerAttachment(attachment: ComposerAttachment): boolean { - const current = $composerAttachments.get() - const index = current.findIndex(item => item.id === attachment.id) +export const updateComposerAttachment = (attachment: ComposerAttachment) => mainComposerScope.update(attachment) - if (index < 0) { - return false - } - - const next = [...current] - next[index] = attachment - $composerAttachments.set(next) - - return true -} - -export function clearComposerAttachments() { - $composerAttachments.set([]) -} +export const clearComposerAttachments = () => mainComposerScope.clear() /** Update only the upload state of an existing attachment (no-op if it's gone, * e.g. the user removed it mid-upload). Pass `undefined` to clear it. */ -export function setComposerAttachmentUploadState(id: string, uploadState?: ComposerAttachment['uploadState']) { - const current = $composerAttachments.get() - const index = current.findIndex(attachment => attachment.id === id) - - if (index < 0) { - return - } - - const next = [...current] - next[index] = { ...next[index]!, uploadState } - $composerAttachments.set(next) -} +export const setComposerAttachmentUploadState = (id: string, uploadState?: ComposerAttachment['uploadState']) => + mainComposerScope.setUploadState(id, uploadState) const TERMINAL_REF_RE = /@terminal:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g diff --git a/apps/desktop/src/store/gateway-switch.test.ts b/apps/desktop/src/store/gateway-switch.test.ts new file mode 100644 index 000000000000..5b24c120ccc9 --- /dev/null +++ b/apps/desktop/src/store/gateway-switch.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $sessionsLimit, resetSessionsLimit, SIDEBAR_SESSIONS_PAGE_SIZE } from '@/store/layout' +import { + $cronSessions, + $freshDraftReady, + $messagingSessions, + $sessions, + $sessionsLoading, + $sessionsTotal, + setCronSessions, + setFreshDraftReady, + setMessagingSessions, + setSessions, + setSessionsLoading, + setSessionsTotal +} from '@/store/session' + +import { $gatewaySwitching, wipeSessionListsForGatewaySwitch } from './gateway-switch' + +vi.mock('@/lib/query-client', () => ({ + queryClient: { invalidateQueries: vi.fn() } +})) + +describe('wipeSessionListsForGatewaySwitch', () => { + beforeEach(() => { + $gatewaySwitching.set(false) + setSessions([{ id: 's1', title: 'old', profile: 'default' } as never]) + setSessionsTotal(1) + setCronSessions([{ id: 'c1', title: 'cron', profile: 'default' } as never]) + setMessagingSessions([{ id: 'm1', title: 'tg', profile: 'default' } as never]) + setSessionsLoading(false) + setFreshDraftReady(false) + $sessionsLimit.set(SIDEBAR_SESSIONS_PAGE_SIZE * 3) + }) + + afterEach(() => { + resetSessionsLimit() + setSessions([]) + setCronSessions([]) + setMessagingSessions([]) + setSessionsLoading(true) + $gatewaySwitching.set(false) + }) + + it('clears lists and arms loading so sidebar skeletons retrigger', () => { + wipeSessionListsForGatewaySwitch() + + expect($sessions.get()).toEqual([]) + expect($sessionsTotal.get()).toBe(0) + expect($cronSessions.get()).toEqual([]) + expect($messagingSessions.get()).toEqual([]) + expect($sessionsLoading.get()).toBe(true) + expect($sessionsLimit.get()).toBe(SIDEBAR_SESSIONS_PAGE_SIZE) + expect($freshDraftReady.get()).toBe(true) + }) +}) diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts new file mode 100644 index 000000000000..e728d57d46f0 --- /dev/null +++ b/apps/desktop/src/store/gateway-switch.ts @@ -0,0 +1,60 @@ +import { atom } from 'nanostores' + +import { queryClient } from '@/lib/query-client' +import { resetSessionsLimit } from '@/store/layout' +import { + setActiveSessionId, + setAttentionSessionIds, + setCronSessions, + setFreshDraftReady, + setMessages, + setMessagingPlatformTotals, + setMessagingSessions, + setMessagingTruncated, + setSelectedStoredSessionId, + setSessionProfileTotals, + setSessions, + setSessionsLoading, + setSessionsTotal, + setUnreadFinishedSessionIds, + setWorkingSessionIds +} from '@/store/session' + +// True while a soft gateway-mode apply is mid-flight (wipe → re-dial). Lets the +// boot hook suppress the backend-exit toast and keeps the cold-boot CONNECTING +// overlay from resurrecting when startHermes re-emits boot progress. +export const $gatewaySwitching = atom(false) + +/** + * Clear gateway-bound session UI so sidebar skeletons retrigger. + * + * Sessions live in nanostores (not React Query) — refreshSessions merges into + * the existing list, so without an explicit wipe a soft switch would keep + * painting the previous gateway's rows. RQ caches (settings/config/skills) are + * invalidated separately; the live session list is this path. + * + * Does NOT call requestFreshSession() — that navigates to NEW_CHAT and would + * close route overlays (Settings). Clear chat state in place; leave the URL + * alone so the user stays where they were (e.g. mid-Gateway settings). + */ +export function wipeSessionListsForGatewaySwitch(): void { + setSessions([]) + setSessionsTotal(0) + setSessionProfileTotals({}) + setCronSessions([]) + setMessagingSessions([]) + setMessagingPlatformTotals({}) + setMessagingTruncated(false) + setWorkingSessionIds([]) + setAttentionSessionIds([]) + setUnreadFinishedSessionIds([]) + setSessionsLoading(true) + resetSessionsLimit() + + setActiveSessionId(null) + setSelectedStoredSessionId(null) + setMessages([]) + setFreshDraftReady(true) + + void queryClient.invalidateQueries() +} diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index ee51119dd786..04c024c22e48 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -164,7 +164,7 @@ function createSecondary(profile: string): Secondary { wantOpen: true } - entry.offEvent = gateway.onEvent(event => config?.onEvent(event)) + entry.offEvent = gateway.onEvent(event => config?.onEvent({ ...event, profile })) entry.offState = gateway.onState(state => { reportGatewayState(profile, state) diff --git a/apps/desktop/src/store/keybinds.ts b/apps/desktop/src/store/keybinds.ts index 05b0732ea026..6aeeb60764ea 100644 --- a/apps/desktop/src/store/keybinds.ts +++ b/apps/desktop/src/store/keybinds.ts @@ -1,33 +1,51 @@ import { atom, computed } from 'nanostores' -import { defaultBindings, KEYBIND_ACTION_IDS, keybindAction, type KeybindBindings } from '@/lib/keybinds/actions' +import { $registryVersion } from '@/contrib/registry' +import { allKeybindActions, defaultBindings, keybindAction, type KeybindBindings } from '@/lib/keybinds/actions' import { canonicalizeCombo } from '@/lib/keybinds/combo' import { arraysEqual, persistString, storedString } from '@/lib/storage' const STORAGE_KEY = 'hermes.desktop.keybinds' -// Defaults overlaid with the user's stored overrides. Unknown / stale action ids -// are dropped; actions added in a later release pick up their shipped default. -function loadBindings(): KeybindBindings { - const base = defaultBindings() +// The user's raw stored overrides. Kept verbatim so an action CONTRIBUTED +// after module init (plugins register late) still resolves its saved rebind — +// `bindingsFor` consults this before falling back to shipped defaults. +function readStoredOverrides(): Record<string, string[]> { const raw = storedString(STORAGE_KEY) if (!raw) { - return base + return {} } try { const parsed = JSON.parse(raw) as Record<string, unknown> + const out: Record<string, string[]> = {} - for (const id of KEYBIND_ACTION_IDS) { - const value = parsed[id] - + for (const [id, value] of Object.entries(parsed)) { if (Array.isArray(value)) { - base[id] = value.filter((combo): combo is string => typeof combo === 'string') + out[id] = value.filter((combo): combo is string => typeof combo === 'string') } } + + return out } catch { // Corrupt storage falls back to defaults. + return {} + } +} + +const storedOverrides = readStoredOverrides() + +// Defaults overlaid with the user's stored overrides. Unknown / stale action +// ids are dropped; actions added in a later release pick up their shipped +// default; late-registered contributed actions resolve via `bindingsFor`. +function loadBindings(): KeybindBindings { + const base = defaultBindings() + + for (const id of Object.keys(base)) { + if (storedOverrides[id]) { + base[id] = storedOverrides[id] + } } return base @@ -39,11 +57,11 @@ function persistBindings(bindings: KeybindBindings): void { const defaults = defaultBindings() const diff: KeybindBindings = {} - for (const id of KEYBIND_ACTION_IDS) { - const current = bindings[id] ?? [] + for (const action of allKeybindActions()) { + const current = bindings[action.id] ?? [] - if (!arraysEqual(current, defaults[id] ?? [])) { - diff[id] = current + if (!arraysEqual(current, defaults[action.id] ?? [])) { + diff[action.id] = current } } @@ -54,18 +72,24 @@ export const $bindings = atom<KeybindBindings>(loadBindings()) $bindings.subscribe(persistBindings) +/** Live combos for an action: explicit binding → stored override → default. */ +export function bindingsFor(id: string, bindings: KeybindBindings = $bindings.get()): string[] { + return bindings[id] ?? storedOverrides[id] ?? [...(keybindAction(id)?.defaults ?? [])] +} + // Reverse lookup combo → actionId for dispatch. First action wins on conflict; // the panel/edit overlay surface conflicts so users can resolve them. Keys go // through `canonicalizeCombo` so a `ctrl+…` binding resolves everywhere. -export const $comboIndex = computed($bindings, bindings => { +// Recomputes on registry mutations so contributed actions dispatch live. +export const $comboIndex = computed([$bindings, $registryVersion], bindings => { const index = new Map<string, string>() - for (const id of KEYBIND_ACTION_IDS) { - for (const combo of bindings[id] ?? []) { + for (const action of allKeybindActions()) { + for (const combo of bindingsFor(action.id, bindings)) { const key = canonicalizeCombo(combo) if (!index.has(key)) { - index.set(key, id) + index.set(key, action.id) } } } @@ -99,7 +123,9 @@ export function resetAllBindings(): void { export function conflictsFor(actionId: string, combo: string): string[] { const bindings = $bindings.get() - return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo)) + return allKeybindActions() + .map(action => action.id) + .filter(id => id !== actionId && bindingsFor(id, bindings).includes(combo)) } // ── Capture ───────────────────────────────────────────────────────────────── diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index b168e35059e3..25cbab5c7b60 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -1,5 +1,8 @@ -import { atom, computed, type ReadableAtom } from 'nanostores' +import { atom, computed, type ReadableAtom, type WritableAtom } from 'nanostores' +import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants' +import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell' +import { matchesQuery } from '@/hooks/use-media-query' import { Codecs, persistentAtom } from '@/lib/persisted' import { arraysEqual, insertUniqueId } from '@/lib/storage' @@ -118,11 +121,6 @@ export const $dismissedWorktreeIds = persistentAtom( Codecs.stringArray ) export const $sidebarPinsOpen = atom(true) -// Set by the PaneShell hover-reveal overlay while the sidebar is collapsed; kept -// true the whole time it's a floating overlay (not just while shown) so the -// consumer mounts contents off-screen, ready to slide. ChatSidebar mounts its -// rows on `sidebarOpen || this`. -export const $sidebarOverlayMounted = atom(false) export const $sidebarRecentsOpen = atom(true) // Cron-job sessions live in their own section below recents, collapsed by // default (it only renders at all when cron sessions exist) so the @@ -183,20 +181,41 @@ export function setSidebarWidth(width: number) { setPaneWidthOverride(CHAT_SIDEBAR_PANE_ID, bounded) } +// Below the collapse breakpoint a collapsible rail leaves the grid and lives as +// a hover/pin overlay, so open/toggle must route through the reveal event — the +// docked `open` flag renders a 0px track invisibly. Centralised here so every +// caller (titlebar, keybinds, session-search, reveal-file) inherits it instead +// of re-deriving the narrow branch. Returns true when it handled the intent. +function revealNarrowPane(id: string, mode: 'close' | 'open' | 'toggle'): boolean { + if (typeof window === 'undefined' || !matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) { + return false + } + + window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id, mode } })) + + return true +} + export function setSidebarOpen(open: boolean) { setPaneOpen(CHAT_SIDEBAR_PANE_ID, open) + revealNarrowPane(CHAT_SIDEBAR_PANE_ID, open ? 'open' : 'close') } export function toggleSidebarOpen() { - togglePane(CHAT_SIDEBAR_PANE_ID) + if (!revealNarrowPane(CHAT_SIDEBAR_PANE_ID, 'toggle')) { + togglePane(CHAT_SIDEBAR_PANE_ID) + } } export function toggleFileBrowserOpen() { - togglePane(FILE_BROWSER_PANE_ID) + if (!revealNarrowPane(FILE_BROWSER_PANE_ID, 'toggle')) { + togglePane(FILE_BROWSER_PANE_ID) + } } export function setFileBrowserOpen(open: boolean) { setPaneOpen(FILE_BROWSER_PANE_ID, open) + revealNarrowPane(FILE_BROWSER_PANE_ID, open ? 'open' : 'close') } // "Reveal this file in the file-browser tree" — an absolute path the tree @@ -233,10 +252,6 @@ export function setSidebarPinsOpen(open: boolean) { $sidebarPinsOpen.set(open) } -export function setSidebarOverlayMounted(mounted: boolean) { - $sidebarOverlayMounted.set(mounted) -} - export function setSidebarRecentsOpen(open: boolean) { $sidebarRecentsOpen.set(open) } @@ -257,12 +272,18 @@ export function setSidebarAgentsGrouped(grouped: boolean) { $sidebarAgentsGrouped.set(grouped) } -export function setSidebarSessionOrderIds(ids: string[]) { - if (!arraysEqual($sidebarSessionOrderIds.get(), ids)) { - $sidebarSessionOrderIds.set(ids) +// Write an order list only when it actually changed, so an identical drag +// result keeps the same array reference and subscribers don't churn. +function setOrderIds($atom: WritableAtom<string[]>, ids: string[]) { + if (!arraysEqual($atom.get(), ids)) { + $atom.set(ids) } } +export function setSidebarSessionOrderIds(ids: string[]) { + setOrderIds($sidebarSessionOrderIds, ids) +} + export function setSidebarSessionOrderManual(manual: boolean) { if ($sidebarSessionOrderManual.get() !== manual) { $sidebarSessionOrderManual.set(manual) @@ -270,21 +291,15 @@ export function setSidebarSessionOrderManual(manual: boolean) { } export function setSidebarWorkspaceOrderIds(ids: string[]) { - if (!arraysEqual($sidebarWorkspaceOrderIds.get(), ids)) { - $sidebarWorkspaceOrderIds.set(ids) - } + setOrderIds($sidebarWorkspaceOrderIds, ids) } export function setSidebarWorkspaceParentOrderIds(ids: string[]) { - if (!arraysEqual($sidebarWorkspaceParentOrderIds.get(), ids)) { - $sidebarWorkspaceParentOrderIds.set(ids) - } + setOrderIds($sidebarWorkspaceParentOrderIds, ids) } export function setSidebarProjectOrderIds(ids: string[]) { - if (!arraysEqual($sidebarProjectOrderIds.get(), ids)) { - $sidebarProjectOrderIds.set(ids) - } + setOrderIds($sidebarProjectOrderIds, ids) } export function setSidebarResizing(resizing: boolean) { @@ -293,20 +308,15 @@ export function setSidebarResizing(resizing: boolean) { export function pinSession(sessionId: string, index?: number) { const prev = $pinnedSessionIds.get() - const next = insertUniqueId(prev, sessionId, index ?? prev.filter(id => id !== sessionId).length) - if (!arraysEqual(prev, next)) { - $pinnedSessionIds.set(next) - } + setOrderIds($pinnedSessionIds, insertUniqueId(prev, sessionId, index ?? prev.filter(id => id !== sessionId).length)) } export function unpinSession(sessionId: string) { - const prev = $pinnedSessionIds.get() - const next = prev.filter(id => id !== sessionId) - - if (!arraysEqual(prev, next)) { - $pinnedSessionIds.set(next) - } + setOrderIds( + $pinnedSessionIds, + $pinnedSessionIds.get().filter(id => id !== sessionId) + ) } // Replace the whole pinned order at once (drag-reorder hands back the new order diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index 17e9964cc815..0bebec57e664 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -284,7 +284,7 @@ describe('OAuth onboarding', () => { return { ok: true, status: 'approved' } } - if (path === '/api/model/options') { + if (path.startsWith('/api/model/options')) { return { providers: [ { @@ -357,7 +357,7 @@ describe('OAuth onboarding', () => { expect(calls.some(c => c.path === '/api/model/set')).toBe(true) - const optionsIndex = calls.findIndex(c => c.path === '/api/model/options') + const optionsIndex = calls.findIndex(c => c.path.startsWith('/api/model/options')) const recommendedIndex = calls.findIndex(c => c.path.startsWith('/api/model/recommended-default')) const setIndex = calls.findIndex(c => c.path === '/api/model/set') diff --git a/apps/desktop/src/store/panes.test.ts b/apps/desktop/src/store/panes.test.ts index 6986ae277114..8ed2fe3723b4 100644 --- a/apps/desktop/src/store/panes.test.ts +++ b/apps/desktop/src/store/panes.test.ts @@ -97,14 +97,14 @@ describe('panes store', () => { expect(getPaneStateSnapshot('files')?.widthOverride).toBeUndefined() }) - it('width override is in-memory only — not persisted across reloads', () => { + it('width override is NOT in-memory only, and is persisted across reloads', () => { ensurePaneRegistered('files', { open: true }) setPaneWidthOverride('files', 300) const persisted = window.localStorage.getItem(STORAGE_KEY) expect(persisted).not.toBeNull() - expect(JSON.parse(persisted ?? '{}')).toEqual({ files: { open: true } }) + expect(JSON.parse(persisted ?? '{}')).toEqual({ files: { open: true, widthOverride: 300 } }) }) it('open flag is persisted across changes', () => { diff --git a/apps/desktop/src/store/panes.ts b/apps/desktop/src/store/panes.ts index 9a67c2c8fbee..a0da806ee200 100644 --- a/apps/desktop/src/store/panes.ts +++ b/apps/desktop/src/store/panes.ts @@ -158,4 +158,26 @@ export function setPaneHeightOverride(id: string, height: number | undefined) { export const clearPaneWidthOverride = (id: string) => setPaneWidthOverride(id, undefined) export const clearPaneHeightOverride = (id: string) => setPaneHeightOverride(id, undefined) + +/** Drop every pane's drag-resize override (open state untouched). Layout + * reset / preset application: zones return to their declared sizes. */ +export function clearAllPaneSizeOverrides() { + const current = $paneStates.get() + let changed = false + const next: Record<string, PaneStateSnapshot> = {} + + for (const [id, state] of Object.entries(current)) { + if (state.widthOverride !== undefined || state.heightOverride !== undefined) { + changed = true + next[id] = { open: state.open } + } else { + next[id] = state + } + } + + if (changed) { + $paneStates.set(next) + } +} + export const getPaneStateSnapshot = (id: string) => $paneStates.get()[id] diff --git a/apps/desktop/src/store/pet-overlay.ts b/apps/desktop/src/store/pet-overlay.ts index 1ab1b64ad232..62fb3f4ba370 100644 --- a/apps/desktop/src/store/pet-overlay.ts +++ b/apps/desktop/src/store/pet-overlay.ts @@ -8,7 +8,7 @@ import { $awaitingResponse, $busy } from '@/store/session' * Controller for the pop-out pet overlay (main-renderer side). * * Shift-clicking the in-window pet "pops it out" into a transparent, - * always-on-top OS window (created in electron/main.cjs) that can leave the + * always-on-top OS window (created in electron/main.ts) that can leave the * app's bounds and stays visible while Hermes is minimized. That window carries * NO gateway connection — this renderer remains the single source of truth and * pushes the live pet state to it over IPC. Control flows back (pop the pet back @@ -30,7 +30,7 @@ export interface PetOverlayBounds { /** * Request to open the overlay window. `screen` says whether `bounds` are already * in absolute screen coordinates (a remembered/dragged spot) or in the main - * window's viewport space (a fresh shift-click pop-out, which main.cjs converts + * window's viewport space (a fresh shift-click pop-out, which main.ts converts * by adding the content origin). */ export interface PetOverlayOpenRequest { @@ -46,6 +46,8 @@ export interface PetOverlayStatePayload { awaiting: boolean /** Drives the overlay's mail icon: a finish landed while you were away. */ unread: boolean + /** Latest reaction — bumping its id forwards a burst to the overlay. */ + reaction: PetReaction | null } export type PetOverlayControl = @@ -67,6 +69,22 @@ export const $petOverlayActive = atom(storedBoolean(OVERLAY_ACTIVE_KEY, false)) // Persist the in/out choice so a popped-out pet comes back popped out. $petOverlayActive.subscribe(active => persistBoolean(OVERLAY_ACTIVE_KEY, active)) +/** + * Reaction signal forwarded to the popped-out overlay window via the state + * mirror below. `id` is a monotonic nonce so the overlay fires once per bump; + * `kind` selects the renderer (today only `vibe` → hearts). Generic on purpose + * so future reactions (emoji, etc.) ride the same channel. + */ +export interface PetReaction { + id: number + kind: string +} + +export const $petReaction = atom<PetReaction | null>(null) + +export const forwardPetReaction = (kind: string) => + $petReaction.set({ id: ($petReaction.get()?.id ?? 0) + 1, kind }) + function loadSavedBounds(): null | PetOverlayBounds { try { const raw = storedString(OVERLAY_BOUNDS_KEY) @@ -129,7 +147,8 @@ function currentPayload(): PetOverlayStatePayload { activity: $petActivity.get(), busy: $busy.get(), awaiting: $awaitingResponse.get(), - unread: $petUnread.get() + unread: $petUnread.get(), + reaction: $petReaction.get() } } @@ -165,14 +184,15 @@ function openOverlay(request: PetOverlayOpenRequest): void { $petActivity.subscribe(pushNow), $busy.subscribe(pushNow), $awaitingResponse.subscribe(pushNow), - $petUnread.subscribe(pushNow) + $petUnread.subscribe(pushNow), + $petReaction.subscribe(pushNow) ] } /** * Pop the pet out of the window. `petRect` is the in-window sprite's viewport * rect; we grow it to the padded overlay size and center the window on the - * pet's old spot (main.cjs adds the window's screen origin). If the user has + * pet's old spot (main.ts adds the window's screen origin). If the user has * popped out before, reopen at that remembered desktop spot instead. */ export function popOutPet(petRect: PetOverlayBounds): void { @@ -273,7 +293,7 @@ export function initPetOverlayBridge(): () => void { // overlay on the next push, keeping both surfaces in sync. scaleHandler?.(payload.scale) } else if (payload?.type === 'open-app') { - // Mail icon: surface the app on the most recent thread (main.cjs already + // Mail icon: surface the app on the most recent thread (main.ts already // focused the window before forwarding this) and mark it read. clearPetUnread() openAppHandler?.() diff --git a/apps/desktop/src/store/pet.ts b/apps/desktop/src/store/pet.ts index 1b189ac82915..b1e2e9d214e5 100644 --- a/apps/desktop/src/store/pet.ts +++ b/apps/desktop/src/store/pet.ts @@ -92,6 +92,9 @@ export function derivePetState(activity: PetActivity): PetState { export const $petInfo = atom<PetInfo>({ enabled: false }) export const $petActivity = atom<PetActivity>({}) +/** Pet installed + enabled with a loaded spritesheet (ready to show/react). */ +export const $petActive = computed($petInfo, info => info.enabled && Boolean(info.spritesheetBase64)) + /** * Profile the pet RPCs should resolve against. Pets are per-profile — the active * pet (`display.pet.*`) and the installed sprites live under each profile's diff --git a/apps/desktop/src/store/preview.test.ts b/apps/desktop/src/store/preview.test.ts index d5d4807ef530..d41a7d9e9468 100644 --- a/apps/desktop/src/store/preview.test.ts +++ b/apps/desktop/src/store/preview.test.ts @@ -48,8 +48,8 @@ describe('preview store', () => { $previewServerRestart.set(null) $activeSessionId.set(null) $selectedStoredSessionId.set(null) - window.localStorage.clear() clearSessionPreviewRegistry() + window.localStorage.clear() }) it('does not notify status subscribers for restart progress text', () => { diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 869ca2cad0d4..7504f6686031 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -1,7 +1,7 @@ import { atom } from 'nanostores' import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups' -import type { HermesGitBranch } from '@/global' +import type { HermesGitBaseBranch, HermesGitBranch } from '@/global' import { translateNow } from '@/i18n' import { desktopDefaultCwd, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs' import { desktopGit } from '@/lib/desktop-git' @@ -11,7 +11,7 @@ import { activeGateway, ensureActiveGatewayOpen } from '@/store/gateway' import { setSidebarAgentsGrouped } from '@/store/layout' import { notify } from '@/store/notifications' import { requestFreshSession } from '@/store/profile' -import { $selectedStoredSessionId, $sessions, workspaceCwdForNewSession } from '@/store/session' +import { $selectedStoredSessionId, $sessions, sessionMatchesStoredId, workspaceCwdForNewSession } from '@/store/session' import type { ProjectInfo, ProjectsPayload } from '@/types/hermes' // First-class, per-profile Projects (named, multi-folder workspaces). State is @@ -585,7 +585,7 @@ function openSessionBelongsToProject(projectId: string, projects: ProjectInfo[]) return false } - const open = $sessions.get().find(s => s.id === openId || s._lineage_root_id === openId) + const open = $sessions.get().find(s => sessionMatchesStoredId(s, openId)) return Boolean(open && liveSessionProjectId(open, projects) === projectId) } @@ -709,6 +709,19 @@ export async function listRepoBranches(repoPath: string): Promise<HermesGitBranc return git.branchList(repoPath) } +// Local + remote-tracking branches for the base-branch picker in the +// new-worktree dialog. The remote default (origin/HEAD) is flagged so the +// UI can preselect it. Empty on a remote backend / non-repo. +export async function listBaseBranches(repoPath: string): Promise<HermesGitBaseBranch[]> { + const git = desktopGit() + + if (!git?.baseBranchList || !repoPath) { + return [] + } + + return git.baseBranchList(repoPath) +} + export async function switchBranchInRepo(repoPath: string, branch: string): Promise<void> { const git = desktopGit() diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts index 285abad10f1d..370ac3ee5bb6 100644 --- a/apps/desktop/src/store/prompts.ts +++ b/apps/desktop/src/store/prompts.ts @@ -1,6 +1,6 @@ import { atom, computed, type ReadableAtom } from 'nanostores' -import { $clarifyRequest } from './clarify' +import { $clarifyRequest, $clarifyRequests } from './clarify' import { $activeSessionId } from './session' // Blocking interactive prompts the gateway raises mid-turn. Each maps to a @@ -23,6 +23,7 @@ interface KeyedPrompt { interface PromptStore<T extends KeyedPrompt> { $active: ReadableAtom<null | T> + $all: ReadableAtom<Record<string, T>> clear: (sessionId?: string | null, requestId?: string) => void reset: () => void set: (request: T) => void @@ -38,6 +39,7 @@ function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> { return { $active: computed([$all, $activeSessionId], (all, activeId) => all[keyFor(activeId)] ?? null), + $all, reset: () => $all.set({}), set: request => $all.set({ ...$all.get(), [keyFor(request.sessionId)]: request }), clear(sessionId, requestId) { @@ -71,8 +73,10 @@ function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> { export interface ApprovalRequest extends KeyedPrompt { // false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow". allowPermanent?: boolean + choices?: string[] command: string description: string + smartDenied?: boolean } export interface SudoRequest extends KeyedPrompt { @@ -88,21 +92,43 @@ export interface SecretRequest extends KeyedPrompt { const approval = keyedPromptStore<ApprovalRequest>() const sudo = keyedPromptStore<SudoRequest>() const secret = keyedPromptStore<SecretRequest>() -const $approvalInlineAnchorCount = atom(0) + +// Inline approval anchors, keyed by session: a tile's inline bar mounting must +// not suppress the PRIMARY session's floating fallback (and vice versa). +const $approvalInlineAnchors = atom<Record<string, number>>({}) export const $approvalRequest = approval.$active export const setApprovalRequest = approval.set export const clearApprovalRequest = approval.clear -export const $approvalInlineVisible = computed($approvalInlineAnchorCount, count => count > 0) -export function registerApprovalInlineAnchor(): () => void { - $approvalInlineAnchorCount.set($approvalInlineAnchorCount.get() + 1) +/** The prompt request for one specific session — the tile counterpart of the + * active-session `$*Request` views (same map, fixed key). */ +export const sessionApprovalRequest = (sessionId: string | null) => + computed(approval.$all, all => all[keyFor(sessionId)] ?? null) +export const sessionSudoRequest = (sessionId: string | null) => + computed(sudo.$all, all => all[keyFor(sessionId)] ?? null) +export const sessionSecretRequest = (sessionId: string | null) => + computed(secret.$all, all => all[keyFor(sessionId)] ?? null) - return () => { - $approvalInlineAnchorCount.set(Math.max(0, $approvalInlineAnchorCount.get() - 1)) +export function registerApprovalInlineAnchor(sessionId: string | null): () => void { + const key = keyFor(sessionId) + + const bump = (delta: number) => { + const all = $approvalInlineAnchors.get() + const next = Math.max(0, (all[key] ?? 0) + delta) + $approvalInlineAnchors.set({ ...all, [key]: next }) } + + bump(1) + + return () => bump(-1) } +/** True when session `sessionId` has an inline approval bar mounted, so its + * floating fallback should stand down. Per-session (not global). */ +export const sessionApprovalInlineVisible = (sessionId: string | null) => + computed($approvalInlineAnchors, anchors => (anchors[keyFor(sessionId)] ?? 0) > 0) + export const $sudoRequest = sudo.$active export const setSudoRequest = sudo.set export const clearSudoRequest = sudo.clear @@ -121,6 +147,20 @@ export const $activeSessionAwaitingInput = computed( (clarify, approval, sudo, secret) => Boolean(clarify || approval || sudo || secret) ) +/** Per-session `awaitingInput` — the tile composer's counterpart of + * `$activeSessionAwaitingInput` (same sources, fixed session instead of the + * active one). */ +export function sessionAwaitingInput(sessionId: string | null) { + return computed( + [$clarifyRequests, approval.$all, sudo.$all, secret.$all], + (clarify, approvals, sudos, secrets) => { + const key = keyFor(sessionId) + + return Boolean(clarify[key] || approvals[key] || sudos[key] || secrets[key]) + } + ) +} + // Drop in-flight prompts for `sessionId` (a turn ended) across all three kinds — // or every parked prompt when no session is given (global reset / tests). export function clearAllPrompts(sessionId?: string | null): void { @@ -128,7 +168,7 @@ export function clearAllPrompts(sessionId?: string | null): void { approval.reset() sudo.reset() secret.reset() - $approvalInlineAnchorCount.set(0) + $approvalInlineAnchors.set({}) return } diff --git a/apps/desktop/src/store/route-tiles.ts b/apps/desktop/src/store/route-tiles.ts new file mode 100644 index 000000000000..0713952c8024 --- /dev/null +++ b/apps/desktop/src/store/route-tiles.ts @@ -0,0 +1,50 @@ +import { atom } from 'nanostores' + +import { readJson, writeJson } from '@/lib/storage' + +import type { SplitDir } from './session-states' + +/** + * Route (page) tiles — a full-page view (Capabilities / Messaging / Artifacts, + * or any plugin route) rendered as a layout-tree pane BESIDE the main thread, + * the page analog of session tiles. Persisted by path so they re-open on boot. + */ +export interface RouteTile { + /** The route path this tile renders, e.g. `/skills`. */ + path: string + /** Edge to dock against main on adoption (default right). */ + dir?: SplitDir +} + +const TILES_KEY = 'hermes.desktop.routeTiles.v1' + +function loadTiles(): RouteTile[] { + const parsed = readJson<unknown>(TILES_KEY) + + return Array.isArray(parsed) + ? parsed + .filter((t): t is RouteTile => Boolean(t && typeof (t as RouteTile).path === 'string')) + .map(t => ({ dir: t.dir, path: t.path })) + : [] +} + +export const $routeTiles = atom<RouteTile[]>(loadTiles()) + +function saveTiles(tiles: RouteTile[]) { + $routeTiles.set(tiles) + writeJson(TILES_KEY, tiles.length === 0 ? null : tiles) +} + +/** Open (or front) a page tile for a route, docked on `dir` (default right). + * Idempotent — an already-open tile keeps its original edge. */ +export function openRouteTile(path: string, dir: SplitDir = 'right') { + const tiles = $routeTiles.get() + + if (!tiles.some(t => t.path === path)) { + saveTiles([...tiles, { dir, path }]) + } +} + +export function closeRouteTile(path: string) { + saveTiles($routeTiles.get().filter(t => t.path !== path)) +} diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts new file mode 100644 index 000000000000..43e5e21978f8 --- /dev/null +++ b/apps/desktop/src/store/session-states.ts @@ -0,0 +1,409 @@ +/** + * MULTI-SESSION VIEW STATE — the reactive face of the per-runtime session + * cache (`sessionStateByRuntimeIdRef` in use-session-state-cache). + * + * The cache already ingests EVERY session's gateway events; only the view + * was single-session ($messages + the active-id gate). This store mirrors + * the cache per runtime id so any number of surfaces (session tiles, future + * pane windows) can each subscribe to one session's state without touching + * the main chat's `$messages` pipeline — same pattern as `useSessionSlice` + * over `$todosBySession`, applied to whole `ClientSessionState`s. + * + * TILES are the first consumer: sessions opened side-by-side with the main + * thread, each in its own layout-tree pane. `$sessionTiles` holds the + * stored-session ids (persisted — tiles survive restarts); the wiring layer + * owns resume/submit (it has the gateway + cache internals) and registers + * itself here as the delegate so tile UI stays dependency-light. + */ + +import { atom, computed } from 'nanostores' + +import type { ClientSessionState } from '@/app/types' +import { findGroup, findGroupOfPane } from '@/components/pane-shell/tree/model' +import { + $activeTreeGroup, + $layoutTree, + moveTreePane, + noteActiveTreeGroup, + revealTreePane +} from '@/components/pane-shell/tree/store' +import { readJson, writeJson } from '@/lib/storage' + +import { $activeGatewayProfile, normalizeProfileKey } from './profile' +import { $activeSessionId, $selectedStoredSessionId } from './session' +import { isSecondaryWindow } from './windows' + +// --------------------------------------------------------------------------- +// Reactive per-runtime session state (view mirror of the wiring cache). +// --------------------------------------------------------------------------- + +export const $sessionStates = atom<Record<string, ClientSessionState>>({}) + +/** Publish one session's state (immutable per-key — slices stay stable). */ +export function publishSessionState(runtimeId: string, state: ClientSessionState) { + $sessionStates.set({ ...$sessionStates.get(), [runtimeId]: state }) +} + +export function dropSessionState(runtimeId: string) { + const current = $sessionStates.get() + + if (!(runtimeId in current)) { + return + } + + const { [runtimeId]: _dropped, ...rest } = current + $sessionStates.set(rest) +} + +// --------------------------------------------------------------------------- +// Session tiles. +// --------------------------------------------------------------------------- + +/** Edge a tile docks against main when it first joins the tree. Shared by + * session tiles and route (page) tiles. */ +export type SplitDir = 'bottom' | 'left' | 'right' | 'top' + +/** Where a tile lands on adoption: an edge split, or `center` = stack into + * the anchor's zone as a tab (a drop on the zone's tab strip). */ +export type TileDock = 'center' | SplitDir + +export interface SessionTile { + /** Stored session id — the durable identity (runtime ids are ephemeral). */ + storedSessionId: string + /** Dock against `anchor` on adoption (default right; center = stack). */ + dir?: TileDock + /** Pane to dock against (a drop's target zone) — default the workspace. + * In-memory only: after first adoption the tree remembers placement. */ + anchor?: string + /** Center docks: stack BEFORE this pane id (`null`/omitted = append) — + * the strip divider's slot. In-memory, like `anchor`. */ + before?: null | string + /** Live runtime id once the tile's resume has bound one. */ + runtimeId?: string + /** Resume failed terminally (shown in the tile; retryable). */ + error?: string +} + +// Tiles are persisted PER PROFILE: a session belongs to one profile, and the +// single live gateway is scoped to one profile at a time, so a tile only makes +// sense while its profile is active. Switching profiles swaps the visible set +// (and drops runtime bindings so each tile re-resumes against the now-current +// gateway — which also settles the "tile resumes against the wrong backend" and +// "stale runtime after respawn" bugs by construction). +const TILES_KEY = 'hermes.desktop.sessionTiles.v2' +const LEGACY_TILES_KEY = 'hermes.desktop.sessionTiles.v1' + +type StoredTile = Pick<SessionTile, 'dir' | 'storedSessionId'> + +const toStored = (t: SessionTile): StoredTile => ({ dir: t.dir, storedSessionId: t.storedSessionId }) + +function parseTileList(value: unknown): StoredTile[] { + return Array.isArray(value) + ? value + .filter((t): t is SessionTile => Boolean(t && typeof (t as SessionTile).storedSessionId === 'string')) + .map(toStored) + : [] +} + +function loadTilesByProfile(): Record<string, StoredTile[]> { + const byProfile: Record<string, StoredTile[]> = {} + const parsed = readJson<unknown>(TILES_KEY) + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + for (const [profile, list] of Object.entries(parsed as Record<string, unknown>)) { + const tiles = parseTileList(list) + + if (tiles.length > 0) { + byProfile[normalizeProfileKey(profile)] = tiles + } + } + } + + // Migrate a v1 flat list into the default profile, then retire the key. + const legacy = parseTileList(readJson<unknown>(LEGACY_TILES_KEY)) + + if (legacy.length > 0) { + const key = normalizeProfileKey('default') + byProfile[key] = [...(byProfile[key] ?? []), ...legacy] + } + + writeJson(LEGACY_TILES_KEY, null) + + return byProfile +} + +const tilesByProfile = loadTilesByProfile() +// Keyed by the GATEWAY profile: the rail's profile switch is a soft swap +// ($activeGatewayProfile moves, no reload) — $activeProfile mirrors the +// window's primary backend and never changes on a rail switch, so keying on +// it left the previous profile's tiles registered (phantom "Session" tabs). +const profileKey = () => normalizeProfileKey($activeGatewayProfile.get()) + +// Runtime ids are process-scoped — never trust a persisted one, so the live +// atom hydrates from the stored (runtime-less) tiles for the active profile. +// A secondary window (single-chat pop-out) shows ONLY its routed session — no +// tiles, and no repopulation on a profile switch. +export const $sessionTiles = atom<SessionTile[]>(isSecondaryWindow() ? [] : [...(tilesByProfile[profileKey()] ?? [])]) + +function persistTiles() { + // Shares the origin's storage; a secondary window holds no tiles, so a write + // back would only wipe the primary's set. + if (isSecondaryWindow()) { + return + } + + writeJson(TILES_KEY, Object.keys(tilesByProfile).length === 0 ? null : tilesByProfile) +} + +function saveTiles(tiles: SessionTile[]) { + $sessionTiles.set(tiles) + const stored = tiles.map(toStored) + + if (stored.length > 0) { + tilesByProfile[profileKey()] = stored + } else { + delete tilesByProfile[profileKey()] + } + + persistTiles() +} + +// Profile switch: surface the new profile's tiles with runtime ids cleared so +// they re-resume against the now-current gateway. (Fires immediately on +// subscribe; harmless — the init value already matches.) A secondary window +// never carries tiles, so it stays out of this entirely. +if (!isSecondaryWindow()) { + $activeGatewayProfile.subscribe(() => { + $sessionTiles.set([...(tilesByProfile[profileKey()] ?? [])]) + }) +} + +export function patchSessionTile(storedSessionId: string, patch: Partial<SessionTile>) { + saveTiles($sessionTiles.get().map(t => (t.storedSessionId === storedSessionId ? { ...t, ...patch } : t))) +} + +/** Drop live runtime bindings so every tile re-resumes — used on gateway + * reconnect, where a respawned backend re-mints (recycles) runtime ids. */ +export function resetTileRuntimeBindings() { + const tiles = $sessionTiles.get() + + if (tiles.some(t => t.runtimeId)) { + $sessionTiles.set(tiles.map(toStored)) + } +} + +// --------------------------------------------------------------------------- +// Delegate — the wiring layer (which owns the gateway + session cache) plugs +// its actions in; tile UI calls through here. Same inversion as the tree +// store's pane closers. +// --------------------------------------------------------------------------- + +export interface SessionTileDelegate { + /** Archive a stored session (the sidebar's archive, incl. tile cleanup). */ + archiveSession(storedSessionId: string): Promise<void> + /** Branch a stored session into a new chat (the sidebar's branch). */ + branchSession(storedSessionId: string): Promise<void> + /** Delete a stored session (the sidebar's delete, incl. tile cleanup). */ + deleteSession(storedSessionId: string): Promise<void> + /** Run a slash command against a tile's session (app-level effects — e.g. + * branch/handoff — act on the main surface, as they should). */ + executeSlash(rawCommand: string, sessionId: string): Promise<void> + /** Interrupt a tile's running turn. */ + interruptSession(runtimeId: string): Promise<void> + /** Bind a live runtime id for a stored session (resume without touching + * the main view). Returns the runtime id, or throws. */ + resumeTile(storedSessionId: string): Promise<string> + /** Submit a prompt to a tile's live session. */ + submitToSession(runtimeId: string, text: string): Promise<void> + /** THE session-state write path — routes through the wiring cache so the + * cache, the primary view (when active), and every tile mirror agree. */ + updateSession(runtimeId: string, updater: (state: ClientSessionState) => ClientSessionState): ClientSessionState +} + +let delegate: SessionTileDelegate | null = null + +export function setSessionTileDelegate(next: SessionTileDelegate) { + delegate = next +} + +export function sessionTileDelegate(): SessionTileDelegate | null { + return delegate +} + +/** Open a tile for a stored session, or MOVE an existing one to the new dock + * (`dir`; `center` = stack into the anchor's zone, `before` = strip slot). The + * move path is what lets a tile's own TAB be dragged like a sidebar row — drop + * it on a zone/edge/strip and the tile goes there (drop-on-a-composer links + * instead, handled by the drag resolver). The session LOADED IN MAIN never + * opens as a tile (same transcript twice, fighting one runtime — silly). */ +export function openSessionTile( + storedSessionId: string, + dir: TileDock = 'right', + anchor?: string, + before?: null | string +) { + const tiles = $sessionTiles.get() + + if (storedSessionId === $selectedStoredSessionId.get()) { + return + } + + if (!tiles.some(t => t.storedSessionId === storedSessionId)) { + saveTiles([...tiles, { anchor, before, dir, storedSessionId }]) + + return + } + + // Already open: relocate the existing pane to the drop target (pane-mirror + // only docks on first adoption, so a re-drag must move the tree pane itself). + const tree = $layoutTree.get() + const target = tree ? findGroupOfPane(tree, anchor ?? 'workspace')?.id : null + + if (target) { + moveTreePane(`${TILE_PANE_PREFIX}${storedSessionId}`, { before: before ?? null, groupId: target, pos: dir }) + } +} + +/** If a session is already ON SCREEN — an open tile OR the one loaded in main — + * front its tab (and focus its zone) and return true. A sidebar click on an + * already-open chat JUMPS to its tab instead of reloading it; `false` means the + * caller must load it into main. Covers the two dead clicks: an open tile, and + * the main session while focus sits on a tile (route unchanged → no reload). */ +export function focusOpenSession(storedSessionId: string): boolean { + if ($sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) { + const paneId = `${TILE_PANE_PREFIX}${storedSessionId}` + revealTreePane(paneId) // un-dismiss + adopt + front in its group + const tree = $layoutTree.get() + const group = tree ? findGroupOfPane(tree, paneId) : null + + if (group) { + noteActiveTreeGroup(group.id) + } + + return true + } + + // Already the main session: front the workspace tab and drop tile focus so + // the readouts + sidebar highlight come home (a no-op when main is focused). + if (storedSessionId === $selectedStoredSessionId.get()) { + revealTreePane('workspace') + noteActiveTreeGroup(null) + + return true + } + + return false +} + +// Closed-tab stack for ⌘⇧T reopen (in-memory) — keyed PER PROFILE like the +// tiles themselves, so ⌘⇧T after a profile switch never resurrects the other +// profile's session. The tile's placement is remembered so it returns in place. +const closedTilesByProfile: Record<string, SessionTile[]> = {} +const closedStack = (): SessionTile[] => (closedTilesByProfile[profileKey()] ??= []) + +export function closeSessionTile(storedSessionId: string) { + const tile = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId) + + if (tile) { + closedStack().push({ anchor: tile.anchor, before: tile.before, dir: tile.dir, storedSessionId }) + } + + saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId)) +} + +/** Drop a DEAD tile — a persisted tile whose session no longer exists on the + * backend (resume 404s). Unlike close, it leaves no ⌘⇧T undo (resurrecting it + * would just 404 again) and evicts any cached state. This is what clears the + * "Session not found" resume spam from stale/cross-profile persisted tiles. */ +export function discardSessionTile(storedSessionId: string) { + const runtimeId = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId)?.runtimeId + + if (runtimeId) { + dropSessionState(runtimeId) + } + + saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId)) +} + +/** ⌘⇧T — reopen the most recently closed tab where it was. Skips ids that are + * live again (reopened, or now the primary). */ +export function reopenLastClosedTile(): void { + const stack = closedStack() + + for (let tile = stack.pop(); tile; tile = stack.pop()) { + const { storedSessionId } = tile + + if (storedSessionId === $selectedStoredSessionId.get()) { + continue + } + + if (!$sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) { + openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before) + + return + } + } +} + +// --------------------------------------------------------------------------- +// The FOCUSED session — one derivation, not another hand-maintained +// "$activeSession" sibling. The layout's interaction tracker ($activeTreeGroup: +// last click/focus, the same source ⌘W uses) resolves to a zone; its active +// pane names the session: a `session-tile:<storedId>` pane IS that session, +// anything else falls back to the route-driven primary. Chrome that should +// follow the user between tiles (titlebar session title, statusbar context / +// timer / model) reads these instead of the primary-only atoms. +// --------------------------------------------------------------------------- + +const TILE_PANE_PREFIX = 'session-tile:' + +/** Stored id of the focused session (the interacted zone's tile, else the + * primary's selection). Null on a fresh draft. */ +export const $focusedStoredSessionId = computed( + [$activeTreeGroup, $layoutTree, $selectedStoredSessionId], + (groupId, tree, selected) => { + const active = groupId && tree ? findGroup(tree, groupId)?.active : undefined + + return active?.startsWith(TILE_PANE_PREFIX) ? active.slice(TILE_PANE_PREFIX.length) : selected + } +) + +/** Live runtime id of the focused session (a tile's bound runtime, else the + * primary's active session). */ +export const $focusedRuntimeId = computed( + [$focusedStoredSessionId, $selectedStoredSessionId, $activeSessionId, $sessionTiles], + (focused, selected, primaryRuntime, tiles) => { + if (focused && focused !== selected) { + return tiles.find(t => t.storedSessionId === focused)?.runtimeId ?? null + } + + return primaryRuntime + } +) + +/** The focused session's state slice (undefined while unresolved/unbound). */ +export const $focusedSessionState = computed([$focusedRuntimeId, $sessionStates], (runtimeId, states) => + runtimeId ? states[runtimeId] : undefined +) + +// A PRIMARY navigation (sidebar resume, route change, new chat) moves focus +// home to the workspace — a previously-clicked tile must not keep owning the +// titlebar/statusbar readouts for a session switch it had no part in. It also +// FRONTS the workspace tab: the resumed chat loads in the workspace pane, so a +// zone parked on a tile tab must switch back or the click looks dead. +$selectedStoredSessionId.listen(() => { + noteActiveTreeGroup(null) + revealTreePane('workspace') +}) + +// Dev hook for automation (mirrors __HERMES_LAYOUT_TREE__). +if (import.meta.env.DEV && typeof window !== 'undefined') { + ;(window as unknown as Record<string, unknown>).__HERMES_SESSION_TILES__ = { + close: closeSessionTile, + open: openSessionTile, + patch: patchSessionTile, + publish: publishSessionState, + states: () => $sessionStates.get(), + tiles: () => $sessionTiles.get() + } +} diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index 013ad0efd480..7ff12111e70d 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SessionInfo } from '@/types/hermes' @@ -7,12 +7,14 @@ import { $attentionSessionIds, $connection, $currentCwd, + $unreadFinishedSessionIds, $workingSessionIds, applyConfiguredDefaultProjectDir, getRecentlySettledSessionIds, mergeSessionPage, sessionPinId, setCurrentCwd, + setSelectedStoredSessionId, setSessionAttention, setSessionWorking, workspaceCwdForNewSession @@ -297,3 +299,48 @@ describe('getRecentlySettledSessionIds', () => { expect(getRecentlySettledSessionIds()).toEqual([]) }) }) + +describe('unread finished sessions', () => { + beforeEach(() => { + $unreadFinishedSessionIds.set([]) + $workingSessionIds.set([]) + setSelectedStoredSessionId(() => null) + }) + + afterEach(() => { + $workingSessionIds.set([]) + $unreadFinishedSessionIds.set([]) + setSelectedStoredSessionId(() => null) + }) + + it('marks a session unread when its turn finishes in the background', () => { + setSelectedStoredSessionId(() => 'other-session') + setSessionWorking('s1', true) + setSessionWorking('s1', false) + expect($unreadFinishedSessionIds.get()).toEqual(['s1']) + }) + + it('does NOT mark unread when the finishing session is the active one', () => { + setSelectedStoredSessionId(() => 's1') + setSessionWorking('s1', true) + setSessionWorking('s1', false) + expect($unreadFinishedSessionIds.get()).toEqual([]) + }) + + it('does NOT mark unread on idle→idle re-asserts (no prior working state)', () => { + setSelectedStoredSessionId(() => 'other-session') + setSessionWorking('s1', false) + setSessionWorking('s1', false) + expect($unreadFinishedSessionIds.get()).toEqual([]) + }) + + it('clears unread when the user opens the session', () => { + setSelectedStoredSessionId(() => 'other') + setSessionWorking('s1', true) + setSessionWorking('s1', false) + expect($unreadFinishedSessionIds.get()).toEqual(['s1']) + + setSelectedStoredSessionId(() => 's1') + expect($unreadFinishedSessionIds.get()).toEqual([]) + }) +}) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 2be408530541..47e10adde8cf 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -28,6 +28,13 @@ const LAST_SESSION_KEY = 'hermes.desktop.lastSessionId' export const getRememberedSessionId = (): null | string => storedString(LAST_SESSION_KEY) export const setRememberedSessionId = (id: null | string) => persistString(LAST_SESSION_KEY, id) +// The last non-overlay route (a page like /skills, or a session route), so a +// relaunch lands back where you were instead of a bare new-chat. +const LAST_ROUTE_KEY = 'hermes.desktop.lastRoute' + +export const getRememberedRoute = (): null | string => storedString(LAST_ROUTE_KEY) +export const setRememberedRoute = (path: null | string) => persistString(LAST_ROUTE_KEY, path) + let configuredDefaultProjectDir = '' function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string { @@ -42,6 +49,7 @@ function workspaceCwdKey(connection: HermesConnection | null = $connection.get() } export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || '' +export type NewChatWorkspaceTarget = null | string | undefined export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir @@ -125,6 +133,14 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) { export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string => session._lineage_root_id ?? session.id +/** True when a stored/lineage id resolves to this session — it matches either + * the live id or the stable lineage root (see sessionPinId). The one place the + * "same conversation across compression" test lives. */ +export const sessionMatchesStoredId = ( + session: Pick<SessionInfo, '_lineage_root_id' | 'id'>, + storedSessionId: string +): boolean => session.id === storedSessionId || session._lineage_root_id === storedSessionId + /** Merge a fresh server session page into the in-memory list, keeping any * row the server omitted that we still want visible — both still-"working" * sessions and pinned sessions. @@ -270,6 +286,8 @@ export const $currentFastMode = atom(storedBoolean(COMPOSER_FAST_KEY, false)) // reflection of the truth the gateway reports rather than its own store. export const $yoloActive = atom(false) export const $currentCwd = atom(getRememberedWorkspaceCwd()) +export const $newChatWorkspaceTarget = atom<NewChatWorkspaceTarget>(undefined) +export const $newChatWorkspaceTargetGeneration = atom(0) export const $currentBranch = atom('') export const $currentUsage = atom<UsageStats>({ calls: 0, @@ -301,7 +319,14 @@ export const setSessionProfileTotals = (next: Updater<Record<string, number>>) = export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next) export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next) export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next) -export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next) +export const setSelectedStoredSessionId = (next: Updater<string | null>) => { + updateAtom($selectedStoredSessionId, next) + // Opening a session clears its unread state — the user is now looking at it. + const id = $selectedStoredSessionId.get() + if (id && $unreadFinishedSessionIds.get().includes(id)) { + toggleMembership(setUnreadFinishedSessionIds, id, false) + } +} export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next) export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next) export const setResumeFailedSessionId = (next: Updater<string | null>) => updateAtom($resumeFailedSessionId, next) @@ -338,6 +363,16 @@ export const setCurrentCwd = (next: Updater<string>) => { persistString(workspaceCwdKey(), $currentCwd.get().trim() || null) } +export const setCurrentCwdTransient = (next: Updater<string>) => updateAtom($currentCwd, next) + +export const setNewChatWorkspaceTarget = (next: NewChatWorkspaceTarget): number => { + const generation = $newChatWorkspaceTargetGeneration.get() + 1 + $newChatWorkspaceTarget.set(next) + $newChatWorkspaceTargetGeneration.set(generation) + + return generation +} + export const workspaceCwdForNewSession = (): string => { if ($connection.get()?.mode === 'remote') { return getRememberedWorkspaceCwd() @@ -484,6 +519,14 @@ const toggleMembership = (set: (next: Updater<string[]>) => void, id: string, on return present ? current.filter(x => x !== id) : current }) +// Stored session ids whose most recent turn finished while the user was +// looking at a different session. The sidebar renders a steady green dot for +// these so the user can tab back and find newly-completed work. Cleared on +// session open (setSelectedStoredSessionId) and on gateway-mode wipe. +export const $unreadFinishedSessionIds = atom<string[]>([]) +export const setUnreadFinishedSessionIds = (next: Updater<string[]>) => + updateAtom($unreadFinishedSessionIds, next) + // Stored session ids with a blocking prompt (clarify) waiting on the user. // Separate from $workingSessionIds: a session can be "working" (turn running) // AND need input. The sidebar row reads this for a persistent indicator that, @@ -520,6 +563,12 @@ export function setSessionWorking(sessionId: string | null | undefined, working: // aggregator to return its now-persisted row. if (wasWorking) { markSessionSettled(sessionId) + + // Mark unread when a background session finishes — only if the user + // isn't currently viewing it. The active session's finish is seen live. + if (sessionId !== $selectedStoredSessionId.get()) { + toggleMembership(setUnreadFinishedSessionIds, sessionId, true) + } } } } diff --git a/apps/desktop/src/store/sidebar-collapse-persistence.test.ts b/apps/desktop/src/store/sidebar-collapse-persistence.test.ts new file mode 100644 index 000000000000..f260950d1b95 --- /dev/null +++ b/apps/desktop/src/store/sidebar-collapse-persistence.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Ground-truth repro for "hiding the sidebar doesn't persist on reload": drive +// the REAL stores, then re-import them (fresh module state reading persisted +// localStorage) to simulate a ⌃R reload. `bind` mirrors the controller wiring. +async function loadStores() { + const layout = await import('./layout') + const tree = await import('@/components/pane-shell/tree/store') + + return { + layout, + tree, + bind: () => tree.bindTreeSideVisibility('left', layout.$sidebarOpen, layout.setSidebarOpen), + leftCollapsed: () => tree.$collapsedTreeSides.get().has('left') + } +} + +const reload = () => vi.resetModules() // fresh modules; localStorage is the carry-over + +describe('sidebar collapse persistence', () => { + beforeEach(() => { + window.localStorage.clear() + vi.resetModules() + }) + + it('restores a hidden sidebar after a reload', async () => { + const s1 = await loadStores() + s1.bind() + s1.layout.setSidebarOpen(false) + expect(s1.leftCollapsed()).toBe(true) + + reload() + const s2 = await loadStores() + expect(s2.layout.$sidebarOpen.get()).toBe(false) // persisted open:false survives + s2.bind() + expect(s2.leftCollapsed()).toBe(true) // and re-collapses + }) + + // The reported repro: a sidebar HIDDEN before a reset must be reopened by the + // reset ("restore everything"); otherwise the stale-hidden state flips the + // next ⌘B into a SHOW, and the user's hide never persists. + it('reset reopens a hidden sidebar, so a later hide persists across reload', async () => { + const s1 = await loadStores() + const { group, split } = await import('@/components/pane-shell/tree/model') + s1.tree.declareDefaultTree(split('row', [group(['sessions']), group(['workspace'])], [1, 3])) + s1.bind() + + s1.layout.setSidebarOpen(false) // hidden BEFORE the reset + expect(s1.leftCollapsed()).toBe(true) + + s1.tree.resetLayoutTree() // ⌘⇧ reset — restores everything, sidebar shown again + expect(s1.layout.$sidebarOpen.get()).toBe(true) + expect(s1.leftCollapsed()).toBe(false) + + s1.layout.toggleSidebarOpen() // ⌘B now genuinely hides + expect(s1.layout.$sidebarOpen.get()).toBe(false) + + reload() + const s2 = await loadStores() + expect(s2.layout.$sidebarOpen.get()).toBe(false) + s2.bind() + expect(s2.leftCollapsed()).toBe(true) + }) +}) diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index c2f5831bc555..7439a8a8345f 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -120,7 +120,7 @@ describe('reportBackendContract', () => { }) it('dismisses the toast when the backend meets the contract', () => { - reportBackendContract(2) + reportBackendContract(3) expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew') expect(notifySpy).not.toHaveBeenCalled() }) @@ -160,8 +160,8 @@ describe('reportBackendContract', () => { lastToast().onDismiss() notifySpy.mockClear() - reportBackendContract(2) // backend updated → satisfied, snooze cleared - reportBackendContract(1) // a later regression must warn immediately + reportBackendContract(3) // backend updated → satisfied, snooze cleared + reportBackendContract(2) // a later regression must warn immediately expect(notifySpy).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index b07347fa88b2..a7b0bbc8b94e 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -91,7 +91,8 @@ function isUpdateToastSnoozed(): boolean { // against. The backend reports its own value in session runtime info; a lower // value (or none — a pre-GUI checkout) means GUI<->backend skew. // v2: requires the file.attach RPC (remote-gateway non-image file upload). -const REQUIRED_BACKEND_CONTRACT = 2 +// v3: requires approvals.mode config RPCs and session.info reconciliation. +const REQUIRED_BACKEND_CONTRACT = 3 const SKEW_TOAST_ID = 'backend-contract-skew' // The contract check runs on every session.resume (applyRuntimeInfo), so // without a snooze the warning re-popped on every thread the user opened, even diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index c5b36ca68554..a881aa4794c6 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -1,7 +1,7 @@ import { notifyError } from './notifications' // Window flag set by the Electron main process when it opens a standalone -// session window (see electron/main.cjs buildSessionWindowUrl). It rides in the +// session window (see electron/main.ts buildSessionWindowUrl). It rides in the // query string BEFORE the HashRouter '#', so we read it from location.search, // never from the router. A "secondary" window renders a single chat without the // global session sidebar or the install / onboarding overlays. diff --git a/apps/desktop/src/store/zoom.ts b/apps/desktop/src/store/zoom.ts index 804f32c68a7b..6fc867c3dddf 100644 --- a/apps/desktop/src/store/zoom.ts +++ b/apps/desktop/src/store/zoom.ts @@ -1,7 +1,7 @@ /** * Window text size (zoom). * - * The main process owns the zoom level and persists it (see electron/zoom.cjs + * The main process owns the zoom level and persists it (see electron/zoom.ts * for the scale). The renderer only mirrors the current percent for the * settings UI: preset clicks go to the main process over IPC, and every * change comes back through onChanged, including ones made with the diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index e5477bb635fb..d1e45ae6ed4b 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -522,6 +522,13 @@ pointer-events: none; } +/* A pane's self-naming label ("REVIEW" atop the review pane) is redundant + when its zone header is showing a tab with the same name — hide it there. + Headerless zones (and the pane used outside the tree) keep the label. */ +[data-tree-group][data-zone-header] [data-pane-self-label] { + display: none; +} + :root:not([style*='--theme-asset-bg:']) .theme-default-filler { display: block; } diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index 575316633fa0..6c24511043f6 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -12,6 +12,7 @@ import { useStore } from '@nanostores/react' import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { $registryVersion } from '@/contrib/registry' import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query' import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage' import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' @@ -19,7 +20,7 @@ import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import { hexToRgb, mix, readableOn } from './color' import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets' import type { DesktopTheme, DesktopThemeColors } from './types' -import { $userThemes, resolveTheme } from './user-themes' +import { $userThemes, listAllThemes, resolveTheme } from './user-themes' // Legacy global skin (pre per-profile themes). Still the inheritance fallback // for any profile without its own assignment, so single-profile users and old @@ -314,18 +315,22 @@ export function ThemeProvider({ children }: { children: ReactNode }) { // behavior is unchanged. const profileKey = normalizeProfileKey(useStore($activeGatewayProfile)) - // Built-ins + user-installed themes. Reactive so an import shows up live in - // the palette, settings grid, and `/skin` without a reload. + // Built-ins + user-installed + registry-contributed themes. Reactive so an + // import or a plugin registration shows up live in the palette, settings + // grid, and `/skin` without a reload. const userThemes = useStore($userThemes) + const registryVersion = useStore($registryVersion) const availableThemes = useMemo( () => - [...Object.values(BUILTIN_THEMES), ...Object.values(userThemes)].map(({ name, label, description }) => ({ + listAllThemes().map(({ name, label, description }) => ({ name, label, description })), - [userThemes] + // userThemes + registryVersion ARE listAllThemes' reactivity. + // eslint-disable-next-line react-hooks/exhaustive-deps + [userThemes, registryVersion] ) const [themeName, setThemeNameState] = useState(() => diff --git a/apps/desktop/src/themes/install.ts b/apps/desktop/src/themes/install.ts index 0958a92a99e1..933203e8dc6d 100644 --- a/apps/desktop/src/themes/install.ts +++ b/apps/desktop/src/themes/install.ts @@ -2,7 +2,7 @@ * Install desktop themes from external sources. * * The heavy lifting (network + .vsix unzip) lives in the Electron main process - * (`electron/vscode-marketplace.cjs`), reached via `window.hermesDesktop.themes`. + * (`electron/vscode-marketplace.ts`), reached via `window.hermesDesktop.themes`. * Main hands back the raw theme JSON; we parse + convert + persist here so the * conversion stays in one unit-testable place. */ diff --git a/apps/desktop/src/themes/user-themes.ts b/apps/desktop/src/themes/user-themes.ts index c4bba521065d..4d63fdb00875 100644 --- a/apps/desktop/src/themes/user-themes.ts +++ b/apps/desktop/src/themes/user-themes.ts @@ -12,6 +12,8 @@ import { atom, computed } from 'nanostores' +import { registry } from '@/contrib/registry' + import { BUILTIN_THEMES } from './presets' import type { DesktopTheme, DesktopThemeColors } from './types' @@ -142,12 +144,41 @@ export const $marketplaceInstalls = computed($userThemes, themes => { return map }) -/** Resolve a theme by name across the merged registry (built-in + user). */ -export function resolveTheme(name: string): DesktopTheme | undefined { - return BUILTIN_THEMES[name] ?? $userThemes.get()[name] +// ── Contributed themes — the `themes` registry area ───────────────────────── +// A data contribution IS a DesktopTheme. Same validity bar as an installed +// theme; built-in names can't be shadowed, and user-installed themes win over +// contributed ones of the same name (the user's explicit install is intent). + +export const THEMES_AREA = 'themes' + +export function contributedThemes(): DesktopTheme[] { + const seen = new Set<string>() + const out: DesktopTheme[] = [] + + for (const c of registry.getArea(THEMES_AREA)) { + const theme = c.data as DesktopTheme | undefined + + if (theme && isValidTheme(theme) && !BUILTIN_THEMES[theme.name] && !seen.has(theme.name)) { + seen.add(theme.name) + out.push(theme) + } + } + + return out } -/** Built-ins first (stable order), then user themes by install order. */ -export function listAllThemes(): DesktopTheme[] { - return [...Object.values(BUILTIN_THEMES), ...Object.values($userThemes.get())] +/** Resolve a theme by name across the merged set (built-in + user + contributed). */ +export function resolveTheme(name: string): DesktopTheme | undefined { + return BUILTIN_THEMES[name] ?? $userThemes.get()[name] ?? contributedThemes().find(theme => theme.name === name) +} + +/** Built-ins first (stable order), then contributed, then user installs. */ +export function listAllThemes(): DesktopTheme[] { + const user = $userThemes.get() + + return [ + ...Object.values(BUILTIN_THEMES), + ...contributedThemes().filter(theme => !user[theme.name]), + ...Object.values(user) + ] } diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index b8dc01fda292..443a53a9361f 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -307,6 +307,7 @@ export interface PaginatedSessions { export interface RpcEvent<T = unknown> { payload?: T + profile?: string session_id?: string type: string } @@ -397,6 +398,7 @@ export interface SessionResumeResponse { } export interface SessionRuntimeInfo { + approval_mode?: 'manual' | 'off' | 'smart' branch?: string config_warning?: string credential_warning?: string @@ -560,6 +562,7 @@ export interface CronJob { last_run_at?: null | string name?: null | string next_run_at?: null | string + no_agent?: boolean prompt?: null | string schedule?: CronJobSchedule schedule_display?: null | string @@ -858,6 +861,8 @@ export interface AuxiliaryModelsResponse { export interface MoaModelSlot { provider: string model: string + /** Optional per-slot reasoning effort — round-tripped, not edited here. */ + reasoning_effort?: string } export interface MoaConfigResponse { @@ -872,6 +877,10 @@ export interface MoaConfigResponse { max_tokens: number reference_models: MoaModelSlot[] reference_temperature: number + /** Optional advisor output cap — round-tripped, not edited here. */ + reference_max_tokens?: number | null + /** Fan-out cadence (per_iteration | user_turn) — round-tripped. */ + fanout?: string } > aggregator: MoaModelSlot diff --git a/apps/desktop/tsconfig.electron.json b/apps/desktop/tsconfig.electron.json new file mode 100644 index 000000000000..0d25682602b5 --- /dev/null +++ b/apps/desktop/tsconfig.electron.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "strict": false, + "noImplicitAny": false, + "noImplicitThis": false, + "strictNullChecks": false, + "strictFunctionTypes": false, + "strictBindCallApply": false, + "strictPropertyInitialization": false, + "noUncheckedIndexedAccess": false, + "exactOptionalPropertyTypes": false, + "ignoreDeprecations": "6.0", + "composite": true, + "declaration": true, + "outDir": "build/electron-types" + }, + "include": ["electron"], + "exclude": ["src"] +} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 270dc9f126ce..43ed639c3c99 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2023", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2023"], + "types": ["node"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, @@ -13,13 +14,13 @@ "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, - "noEmit": true, "jsx": "react-jsx", "paths": { "@/*": ["./src/*"], + "@hermes/plugin-sdk": ["./src/sdk/index.ts"], "@hermes/shared": ["../shared/src/index.ts"] } }, "include": ["src", "../shared/src"], - "references": [] + "references": [{ "path": "./tsconfig.electron.json" }] } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 7b5e162cd28c..4110d53aeebe 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -58,6 +58,7 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), + '@hermes/plugin-sdk': path.resolve(__dirname, './src/sdk/index.ts'), '@hermes/shared': path.resolve(__dirname, '../shared/src'), react: path.resolve(__dirname, '../../node_modules/react'), 'react-dom': path.resolve(__dirname, '../../node_modules/react-dom'), diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts new file mode 100644 index 000000000000..de633561862a --- /dev/null +++ b/apps/desktop/vitest.config.ts @@ -0,0 +1,27 @@ +import type { TestProjectConfiguration } from 'vitest/config'; +import { defineConfig } from 'vitest/config' + +const reactUi: TestProjectConfiguration = { + extends: './vite.config.ts', + test: { + name: 'ui', + environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + globals: true + } +} + +const electronNative: TestProjectConfiguration = { + test: { + name: 'electron', + environment: 'node', + include: ['electron/**/*.test.ts', 'scripts/**.test.{ts,mjs}'] + } +} + +export default defineConfig({ + test: { + projects: [reactUi, electronNative] + } +}) diff --git a/apps/desktop/vitest.setup.ts b/apps/desktop/vitest.setup.ts new file mode 100644 index 000000000000..a671ac3ae971 --- /dev/null +++ b/apps/desktop/vitest.setup.ts @@ -0,0 +1,6 @@ +import '@testing-library/react' + +// React 19 + Testing Library 16: opt into the act environment so render(), +// fireEvent(), and findBy* queries automatically flush state updates without +// spurious "not wrapped in act(...)" warnings. +;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true diff --git a/apps/shared/eslint.config.mjs b/apps/shared/eslint.config.mjs new file mode 100644 index 000000000000..ea43653a9bf7 --- /dev/null +++ b/apps/shared/eslint.config.mjs @@ -0,0 +1,5 @@ +import shared from '../../eslint.config.shared.mjs' + +export default [ + ...shared +] diff --git a/apps/shared/package.json b/apps/shared/package.json index bd1c10a48a6f..3d3c921e14dc 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -8,9 +8,17 @@ }, "types": "./src/index.ts", "scripts": { - "typecheck": "tsc -p . --noEmit" + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "fix": "npm run lint:fix", + "typecheck": "tsc -p . --noEmit", + "check": "npm run typecheck" }, "devDependencies": { - "typescript": "^6.0.3" + "@eslint/js": "^9.39.4", + "eslint": "^9.39.4", + "globals": "^17.4.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.56.1" } } diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index 2cf4ed1dff49..4707ffd8f615 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -23,6 +23,8 @@ export type GatewayEventName = export interface GatewayEvent<P = unknown> { payload?: P + /** Renderer-side source tag added by the Desktop gateway registry. */ + profile?: string session_id?: string type: GatewayEventName } diff --git a/batch_runner.py b/batch_runner.py index 289361989550..f7fc28d7a5c1 100644 --- a/batch_runner.py +++ b/batch_runner.py @@ -1192,7 +1192,7 @@ def main( providers_order (str): Comma-separated list of OpenRouter providers to try in order (e.g. "anthropic,openai,google") provider_sort (str): Sort providers by "price", "throughput", or "latency" (OpenRouter only) max_tokens (int): Maximum tokens for model responses (optional, uses model default if not set) - reasoning_effort (str): OpenRouter reasoning effort level: "none", "minimal", "low", "medium", "high", "xhigh" (default: "medium") + reasoning_effort (str): Reasoning effort: "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra" (default: "medium") reasoning_disabled (bool): Completely disable reasoning/thinking tokens (default: False) prefill_messages_file (str): Path to JSON file containing prefill messages (list of {role, content} dicts) max_samples (int): Only process the first N samples from the dataset (optional, processes all if not set) @@ -1261,7 +1261,7 @@ def main( print("🧠 Reasoning: DISABLED (effort=none)") elif reasoning_effort: # Use specified effort level - valid_efforts = ["none", "minimal", "low", "medium", "high", "xhigh"] + valid_efforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"] if reasoning_effort not in valid_efforts: print(f"❌ Error: --reasoning_effort must be one of: {', '.join(valid_efforts)}") return diff --git a/cli-config.yaml.example b/cli-config.yaml.example index ed7300da71d9..a040c1a24935 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -28,6 +28,7 @@ model: # "xiaomi" - Xiaomi MiMo (requires: XIAOMI_API_KEY) # "arcee" - Arcee AI Trinity models (requires: ARCEEAI_API_KEY) # "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY — https://ollama.com/settings) + # "deepinfra" - DeepInfra (requires: DEEPINFRA_API_KEY) # "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY) # "azure-foundry" - Microsoft Foundry / Azure OpenAI (API key or Entra ID) # "lmstudio" - LM Studio local server (optional: LM_API_KEY, defaults to http://127.0.0.1:1234/v1) @@ -409,6 +410,8 @@ compression: # Trigger compression at this % of model's context limit (default: 0.50 = 50%) # Lower values = more aggressive compression, higher values = compress later + # Models with context windows below 512K are floored at 0.75 (raise-only) so + # compaction doesn't fire with half the window still free; set above 0.75 to override. threshold: 0.50 # Existing Codex gpt-5.5 behavior: raise Hermes' compaction trigger to 85% @@ -503,11 +506,19 @@ prompt_caching: # timeout: 30 # LLM API call timeout (seconds) # download_timeout: 30 # Image HTTP download timeout (seconds) # # Increase for slow connections or self-hosted image servers +# reasoning_effort: "" # Per-task thinking level: none, minimal, low, medium, +# # high, xhigh, max, ultra. Empty = provider default. +# # Works on every auxiliary task block (vision, +# # web_extract, compression, title_generation, curator, +# # background_review, moa_reference, ...). Example: run +# # compression at "low" and vision at "none" to cut +# # side-task latency/cost on reasoning models. # # # Web page scraping / summarization + browser page text extraction # web_extract: # provider: "auto" # model: "" +# reasoning_effort: "low" # # # Gemini 3.1 TTS hidden audio-tag insertion # tts_audio_tags: @@ -739,6 +750,19 @@ agent: # Options: "xhigh" (max), "high", "medium", "low", "minimal", "none" (disable) reasoning_effort: "medium" + # Per-model reasoning effort overrides (optional dict) + # Key: any sensible model spelling works (exact, dots↔dashes interchangeable, + # provider prefix optional). First match wins. + # Value: reasoning effort level (same options as reasoning_effort) + # Override the global reasoning_effort for that specific model. + # NOTE: no `hermes config set` support for this key -- edit YAML directly. + # reasoning_overrides: + # "openrouter/anthropic/claude-opus-4.5": "xhigh" + # "openai/gpt-5": "low" + # "claude-opus-4.6": "high" # bare model name also works + # "deepseek/deepseek-v4-pro": "xhigh" # dots and dashes are interchangeable + reasoning_overrides: {} + # Predefined personalities (use with /personality command) personalities: helpful: "You are a helpful, friendly AI assistant." @@ -1008,6 +1032,34 @@ stt: model: "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe # mistral: # model: "voxtral-mini-latest" # voxtral-mini-latest | voxtral-mini-2602 + # deepinfra: + # # Model id is discovered live from the DeepInfra catalog filtered + # # by the `stt` surface tag — leave `model` blank to take the first + # # live result. Pin only when you need a specific Whisper variant. + # model: "" + +# Text-to-speech. Only the deepinfra block is documented here — the +# remaining providers (edge, openai, xai, minimax, mistral, gemini, +# elevenlabs, neutts, kittentts, piper) inherit sensible defaults from +# DEFAULT_CONFIG in hermes_cli/config.py. +# tts: +# provider: "deepinfra" +# deepinfra: +# # Model id is discovered live from the DeepInfra catalog filtered +# # by the `tts` surface tag — leave `model` blank to take the first +# # live result. +# model: "" +# voice: "default" + +# Image generation. Each provider plugin reads its own ``image_gen.<name>`` +# block; deepinfra discovers models live from +# api.deepinfra.com/v1/openai/models filtered by the ``image-gen`` tag — +# no model id is hardcoded, so retired models disappear automatically. +# image_gen: +# provider: "deepinfra" +# deepinfra: +# # Leave `model` blank for the first live `image-gen`-tagged result. +# model: "" # ============================================================================= # Response Pacing (Messaging Platforms) diff --git a/cli.py b/cli.py index 63b0240ec088..8a8fdf6f5ff5 100644 --- a/cli.py +++ b/cli.py @@ -543,6 +543,8 @@ def load_cli_config() -> Dict[str, Any]: if key == "model": continue # Already handled above if key in file_config: + if isinstance(defaults[key], dict) and file_config[key] is None: + continue if isinstance(defaults[key], dict) and isinstance(file_config[key], dict): defaults[key].update(file_config[key]) else: @@ -1110,6 +1112,19 @@ def _run_cleanup(*, notify_session_finalize: bool = True): ) try: if _active_agent_ref and hasattr(_active_agent_ref, 'shutdown_memory_provider'): + # A /new shortly before exit leaves its end→switch boundary task + # (old-session extraction, LLM-bound) queued on the memory + # manager's serialized worker. shutdown_all()'s drain only waits + # ~5s and cancels queued tasks, so give pending work a bounded + # head start via the manager's own barrier — otherwise a + # "/new then quit" silently drops the old session's extraction. + # The 30s exit watchdog remains the hard backstop. + _mm = getattr(_active_agent_ref, '_memory_manager', None) + if _mm is not None and hasattr(_mm, 'flush_pending'): + try: + _mm.flush_pending(timeout=10) + except Exception: + pass # Forward the agent's own transcript so memory providers' # ``on_session_end`` hooks see the real conversation instead of # an empty list (#15165). ``_session_messages`` is set on @@ -2980,29 +2995,9 @@ def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool: def _strip_leaked_bracketed_paste_wrappers(text: str) -> str: - """Strip leaked bracketed-paste wrapper markers from user-visible text. + from hermes_cli.input_sanitize import strip_leaked_bracketed_paste_wrappers - Defensive normalization for cases where terminal/prompt_toolkit parsing - fails and bracketed-paste markers end up in the buffer as literal text. - - We strip canonical wrappers unconditionally and also handle degraded - visible forms like ``[200~`` / ``[201~`` and ``00~`` / ``01~`` when they - look like wrapper boundaries, not arbitrary user content. - """ - if not text: - return text - - text = ( - text.replace("\x1b[200~", "") - .replace("\x1b[201~", "") - .replace("^[[200~", "") - .replace("^[[201~", "") - ) - text = re.sub(r"(^|[\s\n>:\]\)])\[200~", r"\1", text) - text = re.sub(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])", "", text) - text = re.sub(r"(^|[\s\n>:\]\)])00~", r"\1", text) - text = re.sub(r"01~(?=$|[\s\n<\[\(\):;.,!?])", "", text) - return text + return strip_leaked_bracketed_paste_wrappers(text) def _apply_bracketed_paste_timeout_patch() -> None: @@ -3902,9 +3897,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): ) # Reasoning config (OpenRouter reasoning effort level) - self.reasoning_config = _parse_reasoning_config( - CLI_CONFIG["agent"].get("reasoning_effort", "") - ) + # Per-model override > global reasoning_effort — resolved through the + # shared chokepoint in hermes_constants (Closes #21256). + from hermes_constants import resolve_reasoning_config + self.reasoning_config = resolve_reasoning_config(CLI_CONFIG, self.model) self.service_tier = _parse_service_tier_config( CLI_CONFIG["agent"].get("service_tier", "") ) @@ -4847,6 +4843,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._pet_event = state self._pet_event_until = time.monotonic() + secs + def _on_reaction(self, kind: str) -> None: + """User affection (ily / <3 / good bot), core-detected — the pet's share + of the vibe signal that plays hearts on the TUI/desktop. Flash a celebrate.""" + if kind == "vibe": + self._pet_flash("jump") + def _pet_react_turn_end(self) -> None: """Flash the end-of-turn beat: failed on error, jump on a finished plan, else wave.""" if not self._pet_enabled: @@ -6956,17 +6958,69 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): ) return False + def _launch_session_boundary_memory_flush( + self, + history_snapshot: list, + *, + session_id: Optional[str] = None, + ) -> Optional[list]: + """Stage old-session memory extraction so /new stays responsive. + + The context-engine ``on_session_end`` boundary is delivered + synchronously here: it is cheap (local state clear, no LLM call) and + ordering-sensitive — it must land before ``reset_session_state()`` + rebinds the engine to the new session. + + The memory-provider half (LLM-bound extraction, seconds) is NOT run + here. The returned snapshot is handed by ``new_session()`` to + ``MemoryManager.commit_session_boundary_async`` as a single + end→switch task on the manager's serialized background worker, so + extraction can never race the provider rebinding (providers key off + internal ``_session_id`` state — a late ``on_session_end`` after + ``on_session_switch`` would misattribute the old transcript to the + new session). + + Returns the history snapshot to queue, or ``None`` when there is + nothing to extract (no agent / empty history / no memory manager). + """ + agent = getattr(self, "agent", None) + if not agent or not history_snapshot: + return None + + engine = getattr(agent, "context_compressor", None) + if engine is not None and hasattr(engine, "on_session_end"): + try: + engine.on_session_end(session_id or "", history_snapshot) + except Exception: + logger.debug( + "Context engine on_session_end failed at /new boundary", + exc_info=True, + ) + + # No provider extraction to queue when no memory manager is + # configured — new_session() falls back to the inline switch path. + if getattr(agent, "_memory_manager", None) is None: + return None + return history_snapshot + def new_session(self, silent=False, title=None): """Start a fresh session with a new session ID and cleared agent state.""" + old_session_id = self.session_id + _boundary_snapshot = None if self.agent and self.conversation_history: - # Trigger memory extraction on the old session before session_id rotates. - self.agent.commit_memory_session(self.conversation_history) + # Deliver the context-engine boundary synchronously and get back + # the history snapshot for the deferred provider extraction — + # queued below (after rotation) so /new never blocks on the + # LLM-bound extraction call. + _boundary_snapshot = self._launch_session_boundary_memory_flush( + list(self.conversation_history), + session_id=old_session_id, + ) self._notify_session_boundary("on_session_finalize") elif self.agent: # First session or empty history — still finalize the old session self._notify_session_boundary("on_session_finalize") - old_session_id = self.session_id if self._session_db and old_session_id: # Flush any un-persisted messages from the current turn to the # old session *before* rotating. /new can be called mid-turn @@ -7054,15 +7108,29 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # per-session state (_session_turns, _turn_counter, _document_id). # Fires BEFORE the plugin on_session_reset hook (shell hooks only # see the new id; Python providers see the transition). See #6672. + # + # When the old session has history, end-of-session extraction + # (LLM-bound, seconds) and this switch are queued as ONE task on + # the memory manager's serialized worker — end strictly before + # switch, without blocking /new (#16454). With no history there + # is nothing to extract; switch inline as before. try: _mm = getattr(self.agent, "_memory_manager", None) if _mm is not None: - _mm.on_session_switch( - self.session_id, - parent_session_id=old_session_id or "", - reset=True, - reason="new_session", - ) + if _boundary_snapshot: + _mm.commit_session_boundary_async( + _boundary_snapshot, + new_session_id=self.session_id, + parent_session_id=old_session_id or "", + reason="new_session", + ) + else: + _mm.on_session_switch( + self.session_id, + parent_session_id=old_session_id or "", + reset=True, + reason="new_session", + ) except Exception: pass self._notify_session_boundary("on_session_reset") @@ -7074,7 +7142,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): print("(^_^)v New session started!") - def _consume_pending_resume_selection(self, text: str) -> bool: """Resolve a bare numeric reply that follows a bare ``/resume`` prompt. @@ -8636,7 +8703,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): elif canonical == "compress": self._manual_compress(cmd_original) elif canonical == "usage": - self._show_usage() + self._handle_usage_command(cmd_original) elif canonical == "credits": self._show_credits() elif canonical == "billing": @@ -9061,6 +9128,55 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): + def _owns_process_notification(self, event: dict) -> bool: + """Return whether this CLI session provably owns a delegation event. + + Delegations dispatched before context compression retain the original + session key, so resolve that key to its continuation before comparing. + Missing or foreign keys fail closed and remain queued for their owner. + """ + event_key = str(event.get("session_key") or "") + current_key = str(getattr(self, "session_id", "") or "") + if not event_key or not current_key: + return False + if event_key == current_key: + return True + try: + session_db = getattr(self, "_session_db", None) + resolved_key = ( + session_db.resolve_resume_session_id(event_key) + if session_db is not None + else event_key + ) or event_key + except Exception: + resolved_key = event_key + return str(resolved_key) == current_key + + def _drain_process_notifications(self, consumer: str) -> None: + """Queue background notifications owned by this visible CLI session. + + ``process_registry`` restores durable delegation completions into every + process using the same Hermes profile. Always pass this CLI's stable + session identity when draining so another window cannot claim and mark + delivered a completion that belongs to this one. + """ + from tools.process_registry import process_registry + from tools.async_delegation import ( + claim_event_delivery, + complete_event_delivery, + ) + + session_key = getattr(self, "session_id", "") or "" + for event, synthetic_message in process_registry.drain_notifications( + session_key=session_key, + owns_event=self._owns_process_notification, + ): + claim = claim_event_delivery(event, consumer) + if claim is None: + continue + self._pending_input.put(synthetic_message) + complete_event_delivery(event, claim) + def _drain_interrupt_queue_to_pending_input(self) -> None: """Move stray messages from ``_interrupt_queue`` into ``_pending_input``. @@ -9537,6 +9653,53 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): + def _handle_usage_command(self, cmd_original: str): + """Dispatch `/usage [reset [--force]]`. + + Bare `/usage` keeps the classic display. `/usage reset` redeems one + banked Codex rate-limit reset credit (guarded: refuses when limits + aren't exhausted unless --force). + """ + parts = cmd_original.split() + args = [p.lower() for p in parts[1:]] + if args and args[0] == "reset": + self._usage_reset(force="--force" in args[1:]) + return + if args: + print(f" Unknown /usage subcommand: {' '.join(parts[1:])}. Try /usage or /usage reset [--force].") + return + self._show_usage() + + def _usage_reset(self, force: bool = False): + """`/usage reset [--force]` — redeem one banked Codex reset credit.""" + provider = ( + (getattr(self.agent, "provider", None) if self.agent else None) + or getattr(self, "provider", None) + ) + normalized = str(provider or "").strip().lower() + if normalized != "openai-codex": + print(" Banked usage resets are only available on the openai-codex provider.") + print(" Switch with `/model` or `hermes auth` first.") + return + base_url = (getattr(self.agent, "base_url", None) if self.agent else None) or getattr(self, "base_url", None) + api_key = (getattr(self.agent, "api_key", None) if self.agent else None) or getattr(self, "api_key", None) + + from agent.account_usage import redeem_codex_reset_credit + + print(" ⏳ Checking banked reset credits...") + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as _pool: + try: + result = _pool.submit( + redeem_codex_reset_credit, + base_url=base_url, + api_key=api_key, + force=force, + ).result(timeout=45.0) + except concurrent.futures.TimeoutError: + print(" ❌ Timed out talking to the Codex backend — try again shortly.") + return + print(f" {result.message}") + def _show_usage(self): """Rate limits + session token usage (when a live agent exists) + Nous credits. @@ -11597,13 +11760,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): return "" def _approval_callback(self, command: str, description: str, - *, allow_permanent: bool = True) -> str: + *, allow_permanent: bool = True, + smart_denied: bool = False) -> str: """ Prompt for dangerous command approval through the prompt_toolkit UI. Called from the agent thread. Shows a selection UI similar to clarify - with choices: once / session / always / deny. When allow_permanent - is False (tirith warnings present), the 'always' option is hidden. + with choices: once / session / always / deny. Smart DENY owner + overrides show only once / deny. When allow_permanent is False for + another reason (for example tirith), only 'always' is hidden. Long commands also get a 'view' option so the full command can be expanded before deciding. @@ -11620,7 +11785,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._approval_state = { "command": command, "description": description, - "choices": self._approval_choices(command, allow_permanent=allow_permanent), + "choices": self._approval_choices( + command, + allow_permanent=allow_permanent, + smart_denied=smart_denied, + ), "selected": 0, "response_queue": response_queue, } @@ -11666,9 +11835,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): _cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}") return "deny" - def _approval_choices(self, command: str, *, allow_permanent: bool = True) -> list[str]: + def _approval_choices(self, command: str, *, allow_permanent: bool = True, + smart_denied: bool = False) -> list[str]: """Return approval choices for a dangerous command prompt.""" - choices = ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"] + if smart_denied: + choices = ["once", "deny"] + else: + choices = ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"] if len(command) > 70: choices.append("view") return choices @@ -12033,7 +12206,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): request_overrides=turn_route.get("request_overrides"), ): return None - + agent = self.agent + if agent is None: + return None + # Route image attachments based on the active model's vision capability. # "native" → pass pixels as OpenAI-style content parts (adapters # translate for Anthropic/Gemini/Bedrock). @@ -12121,8 +12297,31 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): from run_agent import _sanitize_surrogates message = _sanitize_surrogates(message) - # Add user message to history - self.conversation_history.append({"role": "user", "content": message}) + # Keep the exact CLI input dict available until turn-start persistence. + # Copy the completed agent transcript before appending: otherwise this + # UI-only staging step mutates ``agent._session_messages`` and exposes a + # duplicate-prone intermediate snapshot to terminal-close persistence. + if self.conversation_history is getattr(agent, "_session_messages", None): + self.conversation_history = list(self.conversation_history) + # The prior turn's override applies only to its own user dict. Clear it + # before exposing the next staged input to close persistence; otherwise + # a shutdown before the worker prologue can write old API-local text as + # this new user message (#63766). + persist_lock = getattr(agent, "_session_persist_lock", None) + + def _stage_user_message() -> None: + agent._persist_user_message_idx = None + agent._persist_user_message_override = None + agent._persist_user_message_timestamp = None + staged_user_message = {"role": "user", "content": message} + agent._pending_cli_user_message = staged_user_message + self.conversation_history.append(staged_user_message) + + if persist_lock is None: + _stage_user_message() + else: + with persist_lock: + _stage_user_message() ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") print(flush=True) @@ -12259,13 +12458,20 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._pending_moa_config = None if _moa_cfg is None: _moa_cfg = None + # Model/skill notes and voice instructions are API-local. Keep + # the original staged input as the durable transcript value so a + # close-path marker follows the same dict into turn setup rather + # than producing a second noted user row (#63766). + _persist_clean_user_message = ( + message if (_voice_prefix or agent_message != message) else None + ) try: result = self.agent.run_conversation( user_message=agent_message, conversation_history=self.conversation_history[:-1], # Exclude the message we just added stream_callback=stream_callback, task_id=self.session_id, - persist_user_message=message if _voice_prefix else None, + persist_user_message=_persist_clean_user_message, moa_config=_moa_cfg, ) if getattr(self, "_pending_moa_disable_after_turn", False): @@ -12728,20 +12934,75 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): if not agent or not hasattr(agent, "_persist_session"): return - messages = getattr(agent, "_session_messages", None) - if not isinstance(messages, list): - messages = getattr(self, "conversation_history", None) - if not isinstance(messages, list) or not messages: - return + persist_lock = getattr(agent, "_session_persist_lock", None) - conversation_history = getattr(self, "conversation_history", None) - if not isinstance(conversation_history, list): - conversation_history = messages + def _snapshot_and_persist() -> None: + # This snapshot must share the staging lock with ``chat()``. Without + # it, close can retain a mutable history baseline just before chat + # appends its pending dict; the later flush then mistakes that dict + # for durable history and stamps it without writing a row (#63766). + messages = getattr(agent, "_session_messages", None) + pending_cli_message = getattr(agent, "_pending_cli_user_message", None) + if not isinstance(messages, list): + messages = getattr(self, "conversation_history", None) + if not isinstance(messages, list): + return + if isinstance(pending_cli_message, dict) and not any( + message is pending_cli_message for message in messages + ): + # The UI has accepted a new input but the worker still exposes its + # prior snapshot. Include only that staged dict; the baseline below + # keeps any durable resumed prefix from being re-appended. + messages = [*messages, pending_cli_message] + if not messages: + return - try: + # A normal turn builds a new list that reuses the resumed-history dicts. + # Keep that CLI history as the baseline so a signal between assigning + # ``_session_messages`` and the turn's DB flush cannot append its durable + # prefix a second time. Once the CLI takes the turn result, however, both + # names can point at the same live list; passing that alias would mark an + # unflushed tail durable without writing it. Marker-only persistence is + # correct only in that alias case. + conversation_history = getattr(self, "conversation_history", None) + pending_cli_message = getattr(agent, "_pending_cli_user_message", None) + if ( + isinstance(conversation_history, list) + and conversation_history + and conversation_history[-1] is pending_cli_message + ): + # The UI accepted this user message before the agent finished its + # early persistence. Its dict can already be in ``messages`` but is + # not durable yet, so exclude it from the resumed-history baseline. + conversation_history = conversation_history[:-1] + elif not isinstance(conversation_history, list) or conversation_history is messages: + conversation_history = None + + # A first-turn close can arrive before the worker builds its cached + # prompt. Build or restore it before the DB row is created so the + # durable transcript never leaves a NULL system_prompt cache entry. + if getattr(agent, "_cached_system_prompt", None) is None: + try: + from agent.conversation_loop import _restore_or_build_system_prompt + + _restore_or_build_system_prompt(agent, None, conversation_history) + except Exception: + logger.debug("Could not build system prompt during CLI close", exc_info=True) + return + if getattr(agent, "_cached_system_prompt", None) is None: + return + + agent._ensure_db_session() agent._persist_session(messages, conversation_history) if getattr(agent, "session_id", None): self.session_id = agent.session_id + + try: + if persist_lock is None: + _snapshot_and_persist() + else: + with persist_lock: + _snapshot_and_persist() except (Exception, KeyboardInterrupt) as e: logger.debug("Could not persist active CLI session before close: %s", e) @@ -15089,11 +15350,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # Check for background process notifications (completions # and watch pattern matches) while agent is idle. try: - from tools.process_registry import process_registry - from tools.approval import get_current_session_key - _drain_sk = get_current_session_key(default="") - for _evt, _synth in process_registry.drain_notifications(session_key=_drain_sk): - self._pending_input.put(_synth) + self._drain_process_notifications("cli-idle") except Exception: pass continue @@ -15253,9 +15510,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # Drain process notifications (completions + watch matches) # that arrived while the agent was running. try: - from tools.process_registry import process_registry - for _evt, _synth in process_registry.drain_notifications(): - self._pending_input.put(_synth) + self._drain_process_notifications("cli-post-turn") except Exception: pass # Non-fatal — don't break the main loop diff --git a/cron/jobs.py b/cron/jobs.py index 76cfc60cbf5d..f81e40f2598e 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -7,6 +7,8 @@ Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md import contextlib import copy +from contextvars import ContextVar +from dataclasses import dataclass import json import logging import shutil @@ -62,6 +64,9 @@ except ImportError: # the default root: that re-breaks per-profile isolation. See also the dynamic # `_get_hermes_home()` / `_get_lock_paths()` resolution in cron/scheduler.py. HERMES_DIR = get_hermes_home().resolve() +# These constants remain the default-profile fallback and a compatibility +# surface for existing callers/tests. Cross-profile callers must scope paths +# with use_cron_store() instead of mutating them process-wide. CRON_DIR = HERMES_DIR / "cron" JOBS_FILE = CRON_DIR / "jobs.json" # Heartbeat file the in-process ticker touches on every loop iteration. The @@ -96,6 +101,50 @@ _JOBS_LOCK_TIMEOUT_SECONDS = 30.0 OUTPUT_DIR = CRON_DIR / "output" ONESHOT_GRACE_SECONDS = 120 + +@dataclass(frozen=True) +class _CronStorePaths: + cron_dir: Path + jobs_file: Path + output_dir: Path + + +_cron_store_override: ContextVar[Optional[_CronStorePaths]] = ContextVar( + "cron_store_override", + default=None, +) + + +def _current_cron_store() -> _CronStorePaths: + """Return paths pinned to this execution context's profile.""" + override = _cron_store_override.get() + if override is not None: + return override + return _CronStorePaths(CRON_DIR, JOBS_FILE, OUTPUT_DIR) + + +@contextlib.contextmanager +def use_cron_store(home: Union[str, Path]): + """Route cron storage to ``home`` without mutating process globals.""" + cron_dir = Path(home).expanduser().resolve() / "cron" + token = _cron_store_override.set( + _CronStorePaths( + cron_dir=cron_dir, + jobs_file=cron_dir / "jobs.json", + output_dir=cron_dir / "output", + ) + ) + try: + yield + finally: + _cron_store_override.reset(token) + + +def get_cron_output_dir() -> Path: + """Return the output directory for the active cron store context.""" + return _current_cron_store().output_dir + + # Fallback stale-recovery window for a one-shot's running-claim (#59229) when # the cron inactivity timeout is disabled (HERMES_CRON_TIMEOUT=0 → unlimited), # in which case no finite run bound exists to derive from. Also acts as the @@ -143,9 +192,35 @@ def _oneshot_run_claim_ttl_seconds() -> float: ) +def _job_running_in_this_process(job_id: str) -> bool: + """Return True when the scheduler in THIS process is still running ``job_id``. + + Direct liveness signal for stale-entry recovery (#62002): the run_claim + TTL alone cannot distinguish "the claiming tick died" from "the run is + alive but slow" — a run stalled on network I/O (or a laptop that slept + mid-run) legitimately outlives the TTL. The in-process ticker and the run + share this process, so the scheduler's running set settles the common + single-gateway case without any claim-age guesswork. + + Imported lazily: the scheduler imports this module at load, so a + module-level import here would be circular. + """ + try: + from cron.scheduler import get_running_job_ids + return job_id in get_running_job_ids() + except Exception: + logger.warning( + "Cron running-set liveness check failed for job %r; keeping the " + "entry to avoid deleting a possibly live one-shot run", + job_id, + exc_info=True, + ) + return True + + def _jobs_lock_file() -> Path: """Return the advisory lock path for the current cron directory.""" - return CRON_DIR / ".jobs.lock" + return _current_cron_store().cron_dir / ".jobs.lock" @contextlib.contextmanager @@ -265,7 +340,7 @@ def _job_output_dir(job_id: str) -> Path: raise ValueError(f"Invalid cron job id for output path: {job_id!r}") if Path(text).is_absolute() or Path(text).drive: raise ValueError(f"Invalid cron job id for output path: {job_id!r}") - return OUTPUT_DIR / text + return _current_cron_store().output_dir / text def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]: @@ -372,10 +447,11 @@ def _secure_file(path: Path): def ensure_dirs(): """Ensure cron directories exist with secure permissions.""" - CRON_DIR.mkdir(parents=True, exist_ok=True) - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - _secure_dir(CRON_DIR) - _secure_dir(OUTPUT_DIR) + store = _current_cron_store() + store.cron_dir.mkdir(parents=True, exist_ok=True) + store.output_dir.mkdir(parents=True, exist_ok=True) + _secure_dir(store.cron_dir) + _secure_dir(store.output_dir) # ============================================================================= @@ -559,7 +635,7 @@ def _recoverable_oneshot_run_at( their requested minute still run on the next tick. Once a one-shot has already run, it is never eligible again. """ - if schedule.get("kind") != "once": + if not isinstance(schedule, dict) or schedule.get("kind") != "once": return None if last_run_at: return None @@ -568,7 +644,10 @@ def _recoverable_oneshot_run_at( if not run_at: return None - run_at_dt = _ensure_aware(datetime.fromisoformat(run_at)) + try: + run_at_dt = _ensure_aware(datetime.fromisoformat(run_at)) + except Exception: + return None if run_at_dt >= now - timedelta(seconds=ONESHOT_GRACE_SECONDS): return run_at return None @@ -592,16 +671,18 @@ def _compute_grace_seconds(schedule: dict) -> int: return max(MIN_GRACE, min(grace, MAX_GRACE)) if kind == "cron" and HAS_CRONITER: - try: - now = _hermes_now() - cron = croniter(schedule["expr"], now) - first = cron.get_next(datetime) - second = cron.get_next(datetime) - period_seconds = int((second - first).total_seconds()) - grace = period_seconds // 2 - return max(MIN_GRACE, min(grace, MAX_GRACE)) - except Exception: - pass + expr = schedule.get("expr") + if expr: + try: + now = _hermes_now() + cron = croniter(expr, now) + first = cron.get_next(datetime) + second = cron.get_next(datetime) + period_seconds = int((second - first).total_seconds()) + grace = period_seconds // 2 + return max(MIN_GRACE, min(grace, MAX_GRACE)) + except Exception: + pass return MIN_GRACE @@ -614,28 +695,41 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None """ now = _hermes_now() - if schedule["kind"] == "once": + if not isinstance(schedule, dict): + return None + kind = schedule.get("kind") + if kind is None: + return None + + if kind == "once": return _recoverable_oneshot_run_at(schedule, now, last_run_at=last_run_at) - elif schedule["kind"] == "interval": - minutes = schedule["minutes"] + elif kind == "interval": + minutes = schedule.get("minutes") + if minutes is None: + return None if last_run_at: - # Next run is last_run + interval - last = _ensure_aware(datetime.fromisoformat(last_run_at)) - next_run = last + timedelta(minutes=minutes) + try: + last = _ensure_aware(datetime.fromisoformat(last_run_at)) + next_run = last + timedelta(minutes=minutes) + except Exception: + next_run = now + timedelta(minutes=minutes) else: # First run is now + interval next_run = now + timedelta(minutes=minutes) return next_run.isoformat() - elif schedule["kind"] == "cron": + elif kind == "cron": + expr = schedule.get("expr") + if not expr: + return None if not HAS_CRONITER: logger.warning( "Cannot compute next run for cron schedule %r: 'croniter' is " "not installed. croniter is a core dependency as of v0.9.x; " "reinstall hermes-agent or run 'pip install croniter' in your " "runtime env.", - schedule.get("expr"), + expr, ) return None # Use last_run_at as the croniter base when available, consistent @@ -644,8 +738,11 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None # rather than to an arbitrary restart time. base_time = now if last_run_at: - base_time = _ensure_aware(datetime.fromisoformat(last_run_at)) - cron = croniter(schedule["expr"], base_time) + try: + base_time = _ensure_aware(datetime.fromisoformat(last_run_at)) + except Exception: + base_time = now + cron = croniter(expr, base_time) next_run = cron.get_next(datetime) return next_run.isoformat() @@ -664,7 +761,7 @@ def _atomic_write_epoch(path: Path) -> None: torn/truncated file. Best-effort: failures are swallowed by callers. """ ensure_dirs() - fd, tmp_path = tempfile.mkstemp(dir=str(CRON_DIR), suffix=".tmp", prefix=".hb_") + fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp", prefix=".hb_") try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(str(time.time())) @@ -730,20 +827,21 @@ def get_ticker_success_age() -> Optional[float]: def load_jobs() -> List[Dict[str, Any]]: """Load all jobs from storage.""" + jobs_file = _current_cron_store().jobs_file ensure_dirs() - if not JOBS_FILE.exists(): + if not jobs_file.exists(): return [] _strict_retry = False # track whether we used the strict=False fallback try: - with open(JOBS_FILE, 'r', encoding='utf-8') as f: + with open(jobs_file, 'r', encoding='utf-8') as f: data = json.load(f) except json.JSONDecodeError: # Retry with strict=False to handle bare control chars in string values _strict_retry = True try: - with open(JOBS_FILE, 'r', encoding='utf-8') as f: + with open(jobs_file, 'r', encoding='utf-8') as f: data = json.loads(f.read(), strict=False) except Exception as e: logger.error("Failed to auto-repair jobs.json: %s", e) @@ -778,15 +876,16 @@ def load_jobs() -> List[Dict[str, Any]]: def _save_jobs_unlocked(jobs: List[Dict[str, Any]]): """Save all jobs to storage. Caller must hold _jobs_lock().""" + jobs_file = _current_cron_store().jobs_file ensure_dirs() - fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent), suffix='.tmp', prefix='.jobs_') + fd, tmp_path = tempfile.mkstemp(dir=str(jobs_file.parent), suffix='.tmp', prefix='.jobs_') try: with os.fdopen(fd, 'w', encoding='utf-8') as f: json.dump({"jobs": jobs, "updated_at": _hermes_now().isoformat()}, f, indent=2) f.flush() os.fsync(f.fileno()) - atomic_replace(tmp_path, JOBS_FILE) - _secure_file(JOBS_FILE) + atomic_replace(tmp_path, jobs_file) + _secure_file(jobs_file) except BaseException: try: os.unlink(tmp_path) @@ -1452,7 +1551,7 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None, "Job '%s' (%s) could not compute next_run_at; " "leaving enabled and marking state=error so the " "job is not silently disabled.", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), kind, ) else: @@ -1506,7 +1605,7 @@ def claim_dispatch(job_id: str) -> bool: save_jobs(jobs) logger.info( "Job '%s': dispatch limit reached (%d/%d) — removing", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), completed, times, ) @@ -1516,7 +1615,7 @@ def claim_dispatch(job_id: str) -> bool: save_jobs(jobs) logger.debug( "Job '%s': claimed dispatch %d/%d", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), repeat["completed"], times, ) @@ -1530,6 +1629,38 @@ def claim_dispatch(job_id: str) -> bool: return True +def heartbeat_run_claim(job_id: str, *, expected_owner: str) -> bool: + """Refresh a one-shot's ``run_claim`` timestamp while its run is alive. + + Called periodically from the scheduler's run monitor (#62002) so a + legitimately long run keeps its claim fresh: an expired claim then really + does mean "the claiming process died", and neither another process's tick + nor this process's own next tick will re-dispatch or stale-remove the job + while the run is in flight. mark_job_run() clears the claim on completion. + + ``expected_owner`` is the stable owner copied from the dispatched job. The + compare-and-refresh prevents a stale runner that resumes after a long sleep + from extending a claim another scheduler process has since taken over. + + Returns True if this owner's one-shot claim was refreshed; False when the + job, claim, or ownership no longer matches. + """ + with _jobs_lock(): + jobs = load_jobs() + for job in jobs: + if job.get("id") != job_id: + continue + if job.get("schedule", {}).get("kind") != "once": + return False + claim = job.get("run_claim") + if not isinstance(claim, dict) or claim.get("by") != expected_owner: + return False + claim["at"] = _hermes_now().isoformat() + save_jobs(jobs) + return True + return False + + def advance_next_run(job_id: str) -> bool: """Preemptively advance next_run_at for a recurring job before execution. @@ -1653,209 +1784,331 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: """Inner implementation of get_due_jobs(); must be called with _jobs_lock held.""" now = _hermes_now() raw_jobs = load_jobs() + needs_save = False + + # Repair id-less records BEFORE anything keys off ``job["id"]``. A direct + # jobs.json edit that bypassed add_job() can leave a record without an "id" + # (older writers used "job_id"). Every downstream site — the logging + # helpers and the ``for rj in raw_jobs: if rj["id"] == job["id"]`` + # persistence loops — indexes job["id"] eagerly, so a single malformed + # record raised KeyError mid-tick, aborting the whole scan before + # save_jobs() ran. That froze the entire profile's scheduler in a + # per-minute fast-forward loop (healthy jobs recomputed in memory, then + # discarded when the exception unwound). Recover the id from the drifted + # "job_id" key when present, else synthesize one, and persist. + for rj in raw_jobs: + if not rj.get("id"): + rj["id"] = rj.pop("job_id", None) or uuid.uuid4().hex[:12] + needs_save = True + jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)] due = [] - needs_save = False + + # Normalize malformed "schedule" records (direct jobs.json edit, old writers, + # corruption, etc.). "schedule" must be a dict; a null/string/etc. value + # makes `schedule.get("kind")` or direct `schedule["kind"]` / ["expr"] / + # ["minutes"] later raise and abort the entire scan *before* save_jobs(). + # Healthy jobs then lose their fast-forwarded next_run_at (exactly the + # failure mode of the id-less job bug fixed above). Repair early at the + # source so the rest of the tick can proceed and persist progress for + # siblings. + for j in jobs: + if not isinstance(j.get("schedule"), dict): + j["schedule"] = {} + needs_save = True + for rj in raw_jobs: + if not isinstance(rj.get("schedule"), dict): + rj["schedule"] = {} + needs_save = True + + # Normalize malformed "next_run_at" records (direct jobs.json edit, + # corruption, migration, or buggy writer). If present but not a valid + # ISO string, datetime.fromisoformat(next_run) later raises and aborts + # the entire scan *before* save_jobs(). Healthy siblings then lose any + # fast-forwarded next_run_at (same class of bug as bad "id" or "schedule"). + # Strip the bad value so the existing "no next_run_at" recovery path + # recomputes a sane value and persists it for this job. + for j in jobs: + nr = j.get("next_run_at") + if nr is not None: + if not isinstance(nr, str): + j.pop("next_run_at", None) + needs_save = True + else: + try: + datetime.fromisoformat(nr) + except Exception: + j.pop("next_run_at", None) + needs_save = True + for rj in raw_jobs: + nr = rj.get("next_run_at") + if nr is not None: + if not isinstance(nr, str): + rj.pop("next_run_at", None) + needs_save = True + else: + try: + datetime.fromisoformat(nr) + except Exception: + rj.pop("next_run_at", None) + needs_save = True + + # Same treatment for last_run_at (used as base in recovery / compute_next_run). + for j in jobs: + lr = j.get("last_run_at") + if lr is not None and not isinstance(lr, str): + j.pop("last_run_at", None) + needs_save = True + elif isinstance(lr, str): + try: + datetime.fromisoformat(lr) + except Exception: + j.pop("last_run_at", None) + needs_save = True + for rj in raw_jobs: + lr = rj.get("last_run_at") + if lr is not None and not isinstance(lr, str): + rj.pop("last_run_at", None) + needs_save = True + elif isinstance(lr, str): + try: + datetime.fromisoformat(lr) + except Exception: + rj.pop("last_run_at", None) + needs_save = True + # Resolve the one-shot running-claim stale-recovery TTL once per scan # (derived from HERMES_CRON_TIMEOUT). See _oneshot_run_claim_ttl_seconds. _run_claim_ttl = _oneshot_run_claim_ttl_seconds() for job in jobs: - if not job.get("enabled", True): - continue - - # Cross-process running-claim guard (#59229): if another scheduler - # process already claimed this one-shot and its run is still in flight - # (claim younger than the TTL), skip it — do NOT re-dispatch. The - # claim is stamped just before we return the job as due (below) and - # cleared by mark_job_run() on completion. A claim older than the TTL - # is treated as stale (the claiming tick died mid-run) and allowed - # through so the job is recovered rather than wedged forever. - existing_claim = job.get("run_claim") - if existing_claim and job.get("schedule", {}).get("kind") == "once": - try: - claimed_at = _ensure_aware( - datetime.fromisoformat(existing_claim["at"]) - ) - # 0 <= age: a future-dated claim (clock/TZ skew across a - # restart) must be treated as stale, not eternally fresh, - # or the one-shot is skipped forever (#60703). - _age = (now - claimed_at).total_seconds() - if 0 <= _age < _run_claim_ttl: - continue # a fresh claim is held by an in-flight run - except (KeyError, ValueError, TypeError): - pass # malformed claim → fall through and (re)claim - - next_run = job.get("next_run_at") - if not next_run: - schedule = job.get("schedule", {}) - kind = schedule.get("kind") - - # One-shot jobs use a small grace window via the dedicated helper. - recovered_next = _recoverable_oneshot_run_at( - schedule, - now, - last_run_at=job.get("last_run_at"), - ) - recovery_kind = "one-shot" if recovered_next else None - - # Recurring jobs reach here only when something — typically a - # direct jobs.json edit that bypassed add_job() — left - # next_run_at unset. Without this branch, such jobs are - # silently skipped forever; recompute next_run_at from the - # schedule so they pick up at their next scheduled tick. - if not recovered_next and kind in {"cron", "interval"}: - recovered_next = compute_next_run(schedule, now.isoformat()) - if recovered_next: - recovery_kind = kind - - if not recovered_next: + # Per-job containment (structural guard): one malformed or + # unexpected job record must never abort the whole scan. The id / + # schedule / timestamp normalizations above repair the known shapes; + # this guard catches every FUTURE variant, degrading to "skip this + # job this tick" so healthy siblings still run and their recovered + # state still reaches save_jobs() below. + try: + if not job.get("enabled", True): continue - job["next_run_at"] = recovered_next - next_run = recovered_next - logger.info( - "Job '%s' had no next_run_at; recovering %s run at %s", - job.get("name", job["id"]), - recovery_kind, - recovered_next, - ) - for rj in raw_jobs: - if rj["id"] == job["id"]: - rj["next_run_at"] = recovered_next - needs_save = True - break + # Cross-process running-claim guard (#59229): if another scheduler + # process already claimed this one-shot and its run is still in flight + # (claim younger than the TTL), skip it — do NOT re-dispatch. The + # claim is stamped just before we return the job as due (below) and + # cleared by mark_job_run() on completion. A claim older than the TTL + # is treated as stale (the claiming tick died mid-run) and allowed + # through so the job is recovered rather than wedged forever. + existing_claim = job.get("run_claim") + if existing_claim and job.get("schedule", {}).get("kind") == "once": + try: + claimed_at = _ensure_aware( + datetime.fromisoformat(existing_claim["at"]) + ) + # 0 <= age: a future-dated claim (clock/TZ skew across a + # restart) must be treated as stale, not eternally fresh, + # or the one-shot is skipped forever (#60703). + _age = (now - claimed_at).total_seconds() + if 0 <= _age < _run_claim_ttl: + continue # a fresh claim is held by an in-flight run + except (KeyError, ValueError, TypeError): + pass # malformed claim → fall through and (re)claim - raw_next_run_dt = datetime.fromisoformat(next_run) - schedule = job.get("schedule", {}) - kind = schedule.get("kind") + next_run = job.get("next_run_at") + if not next_run: + schedule = job.get("schedule", {}) + kind = schedule.get("kind") - next_run_dt = _ensure_aware(raw_next_run_dt) - # Migration repair: a cron job persists next_run_at as an absolute - # instant, but the cron expr describes local wall-clock intent. If the - # configured/system timezone changed after persistence, the stored - # instant's offset no longer matches now's, and its converted time can - # look due hours early (21:00+10 -> 13:00+02). When the stored *wall - # clock* is still in the future, recompute from the schedule so we fire - # at the intended local time instead of early-then-again. - # - # TRADE-OFF: this cannot distinguish a config/host TZ migration from a - # legitimate DST offset change. A DST boundary that satisfies all four - # conditions will recompute (and thus SKIP the pending occurrence, no - # catch-up) rather than fire it. Accepted: in the pure-migration case - # the recompute lands on the same wall-clock time later the same period, - # and DST-boundary collisions with a still-future stored wall clock are - # rare relative to the double-fire bug this prevents (#28934). - if ( - kind == "cron" - and next_run_dt <= now - and _timezone_offset_mismatch(raw_next_run_dt, now) - and _stored_wall_clock_is_future(raw_next_run_dt, now) - ): - new_next = compute_next_run(schedule, now.isoformat()) - if new_next: + # One-shot jobs use a small grace window via the dedicated helper. + recovered_next = _recoverable_oneshot_run_at( + schedule, + now, + last_run_at=job.get("last_run_at"), + ) + recovery_kind = "one-shot" if recovered_next else None + + # Recurring jobs reach here only when something — typically a + # direct jobs.json edit that bypassed add_job() — left + # next_run_at unset. Without this branch, such jobs are + # silently skipped forever; recompute next_run_at from the + # schedule so they pick up at their next scheduled tick. + if not recovered_next and kind in {"cron", "interval"}: + recovered_next = compute_next_run(schedule, now.isoformat()) + if recovered_next: + recovery_kind = kind + + if not recovered_next: + continue + + job["next_run_at"] = recovered_next + next_run = recovered_next logger.info( - "Job '%s' next_run_at offset changed (%s -> %s). " - "Recomputing cron run to preserve local wall-clock intent: %s", - job.get("name", job["id"]), - raw_next_run_dt.utcoffset(), - now.utcoffset(), - new_next, + "Job '%s' had no next_run_at; recovering %s run at %s", + job.get("name", job.get("id", "?")), + recovery_kind, + recovered_next, ) for rj in raw_jobs: if rj["id"] == job["id"]: - rj["next_run_at"] = new_next + rj["next_run_at"] = recovered_next needs_save = True break - continue - if next_run_dt <= now: + raw_next_run_dt = datetime.fromisoformat(next_run) + schedule = job.get("schedule", {}) + kind = schedule.get("kind") - # For recurring jobs, check if the scheduled time is stale - # (gateway was down and missed the window). Fast-forward to - # the next future occurrence instead of firing a stale run. - grace = _compute_grace_seconds(schedule) - if kind in {"cron", "interval"} and (now - next_run_dt).total_seconds() > grace: - # Job is past its catch-up grace window — skip accumulated - # missed runs but still execute once now to avoid deferring - # indefinitely (e.g. a long-running job just finished). + next_run_dt = _ensure_aware(raw_next_run_dt) + # Migration repair: a cron job persists next_run_at as an absolute + # instant, but the cron expr describes local wall-clock intent. If the + # configured/system timezone changed after persistence, the stored + # instant's offset no longer matches now's, and its converted time can + # look due hours early (21:00+10 -> 13:00+02). When the stored *wall + # clock* is still in the future, recompute from the schedule so we fire + # at the intended local time instead of early-then-again. + # + # TRADE-OFF: this cannot distinguish a config/host TZ migration from a + # legitimate DST offset change. A DST boundary that satisfies all four + # conditions will recompute (and thus SKIP the pending occurrence, no + # catch-up) rather than fire it. Accepted: in the pure-migration case + # the recompute lands on the same wall-clock time later the same period, + # and DST-boundary collisions with a still-future stored wall clock are + # rare relative to the double-fire bug this prevents (#28934). + if ( + kind == "cron" + and next_run_dt <= now + and _timezone_offset_mismatch(raw_next_run_dt, now) + and _stored_wall_clock_is_future(raw_next_run_dt, now) + ): new_next = compute_next_run(schedule, now.isoformat()) if new_next: logger.info( - "Job '%s' missed its scheduled time (%s, grace=%ds). " - "Running now; next run provisionally set to: %s " - "(re-anchored on completion)", - job.get("name", job["id"]), - next_run, - grace, + "Job '%s' next_run_at offset changed (%s -> %s). " + "Recomputing cron run to preserve local wall-clock intent: %s", + job.get("name", job.get("id", "?")), + raw_next_run_dt.utcoffset(), + now.utcoffset(), new_next, ) - # Persist the fast-forward to storage now (skip accumulated - # slots). In the built-in ticker path this is shortly - # overwritten by advance_next_run + mark_job_run, but it is - # NOT redundant: it (a) protects the crash window between - # here and mark_job_run, and (b) covers the external - # fire_due provider path, which does not call - # advance_next_run. mark_job_run re-anchors next_run_at off - # the actual completion time, so this value is provisional. for rj in raw_jobs: if rj["id"] == job["id"]: rj["next_run_at"] = new_next needs_save = True break - # Fall through to due.append(job) — execute once now + continue - # One-shot dispatch-limit guard (issue #38758): a finite one-shot - # claimed via claim_dispatch() but whose tick died before - # mark_job_run could remove it will have completed >= times while - # still looking due (last_run_at was never written, so the - # recovery helper re-armed it). Remove it instead of re-firing. - if kind == "once": - repeat = job.get("repeat") - if repeat: - times = repeat.get("times") - completed = repeat.get("completed", 0) - if times is not None and times > 0 and completed >= times: + if next_run_dt <= now: + + # For recurring jobs, check if the scheduled time is stale + # (gateway was down and missed the window). Fast-forward to + # the next future occurrence instead of firing a stale run. + grace = _compute_grace_seconds(schedule) + if kind in {"cron", "interval"} and (now - next_run_dt).total_seconds() > grace: + # Job is past its catch-up grace window — skip accumulated + # missed runs but still execute once now to avoid deferring + # indefinitely (e.g. a long-running job just finished). + new_next = compute_next_run(schedule, now.isoformat()) + if new_next: logger.info( - "Job '%s': one-shot dispatch limit reached (%d/%d) " - "— removing stale due entry", - job.get("name", job["id"]), - completed, - times, + "Job '%s' missed its scheduled time (%s, grace=%ds). " + "Running now; next run provisionally set to: %s " + "(re-anchored on completion)", + job.get("name", job.get("id", "?")), + next_run, + grace, + new_next, ) + # Persist the fast-forward to storage now (skip accumulated + # slots). In the built-in ticker path this is shortly + # overwritten by advance_next_run + mark_job_run, but it is + # NOT redundant: it (a) protects the crash window between + # here and mark_job_run, and (b) covers the external + # fire_due provider path, which does not call + # advance_next_run. mark_job_run re-anchors next_run_at off + # the actual completion time, so this value is provisional. for rj in raw_jobs: if rj["id"] == job["id"]: - raw_jobs.remove(rj) + rj["next_run_at"] = new_next needs_save = True break - continue + # Fall through to due.append(job) — execute once now - # Durably claim a one-shot for the DURATION of its run before - # returning it as due, so a second scheduler process (gateway + - # desktop both run in-process 60s tickers on one HERMES_HOME) - # cannot re-dispatch it while the first run is still in flight - # (#59229). A plain one-shot's due-state is not resolved until - # mark_job_run() completes it minutes later, so advancing - # next_run_at by a fixed window is not enough — a job that outlives - # one tick (e.g. a 2.5-min research prompt) would simply re-fire on - # the next tick after the window. Instead we stamp a run_claim under - # the same lock get_due_jobs already holds; the other process reads - # a fresh claim on its next tick and skips (handled at the top of - # this loop). mark_job_run() clears the claim on completion. The TTL - # is only a safety valve: a claiming tick that DIES mid-run leaves a - # stale claim that expires after the resolved run-claim TTL - # (_oneshot_run_claim_ttl_seconds, derived from HERMES_CRON_TIMEOUT), - # so the job is re-dispatched rather than wedged forever. - if kind == "once": - claim = {"at": now.isoformat(), "by": _machine_id()} - job["run_claim"] = claim - for rj in raw_jobs: - if rj["id"] == job["id"]: - rj["run_claim"] = claim - needs_save = True - break + # One-shot dispatch-limit guard (issue #38758): a finite one-shot + # claimed via claim_dispatch() but whose tick died before + # mark_job_run could remove it will have completed >= times while + # still looking due (last_run_at was never written, so the + # recovery helper re-armed it). Remove it instead of re-firing. + if kind == "once": + repeat = job.get("repeat") + if repeat: + times = repeat.get("times") + completed = repeat.get("completed", 0) + if times is not None and times > 0 and completed >= times: + # A live run must never have its job record deleted + # underneath it (#62002): a run that outlives the + # run_claim TTL (stream stall, laptop asleep + # mid-run) satisfies the same completed >= times + + # expired-claim condition as a dead tick, but + # mark_job_run() still needs the record to land + # last_run_at / last_status / last_delivery_error. + # If this process is still running the job, it is + # slow, not stale — keep the entry and skip. + if _job_running_in_this_process(job.get("id", "")): + logger.info( + "Job '%s': dispatch limit reached (%d/%d) " + "but its run is still in flight in this " + "process — keeping entry", + job.get("name", job.get("id", "?")), + completed, + times, + ) + continue + logger.info( + "Job '%s': one-shot dispatch limit reached (%d/%d) " + "— removing stale due entry", + job.get("name", job.get("id", "?")), + completed, + times, + ) + for rj in raw_jobs: + if rj["id"] == job["id"]: + raw_jobs.remove(rj) + needs_save = True + break + continue - due.append(job) + # Durably claim a one-shot for the DURATION of its run before + # returning it as due, so a second scheduler process (gateway + + # desktop both run in-process 60s tickers on one HERMES_HOME) + # cannot re-dispatch it while the first run is still in flight + # (#59229). A plain one-shot's due-state is not resolved until + # mark_job_run() completes it minutes later, so advancing + # next_run_at by a fixed window is not enough — a job that outlives + # one tick (e.g. a 2.5-min research prompt) would simply re-fire on + # the next tick after the window. Instead we stamp a run_claim under + # the same lock get_due_jobs already holds; the other process reads + # a fresh claim on its next tick and skips (handled at the top of + # this loop). mark_job_run() clears the claim on completion. The TTL + # is only a safety valve: a claiming tick that DIES mid-run leaves a + # stale claim that expires after the resolved run-claim TTL + # (_oneshot_run_claim_ttl_seconds, derived from HERMES_CRON_TIMEOUT), + # so the job is re-dispatched rather than wedged forever. + if kind == "once": + claim = {"at": now.isoformat(), "by": _machine_id()} + job["run_claim"] = claim + for rj in raw_jobs: + if rj["id"] == job["id"]: + rj["run_claim"] = claim + needs_save = True + break + + due.append(job) + except Exception: + logger.exception( + "Skipping malformed cron job %r during due scan", + job.get("name") or job.get("id") or "?", + ) + continue if needs_save: save_jobs(raw_jobs) diff --git a/cron/scheduler.py b/cron/scheduler.py index 9e645d3728f3..176561c89f70 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -20,6 +20,7 @@ import shutil import subprocess import sys import threading +import time # fcntl is Unix-only; on Windows use msvcrt for file locking try: @@ -237,7 +238,7 @@ _LEGACY_HOME_TARGET_ENV_VARS = { "QQBOT_HOME_CHANNEL": "QQ_HOME_CHANNEL", } -from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run, claim_dispatch +from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run, claim_dispatch, heartbeat_run_claim # Sentinel: when a cron agent has nothing new to report, it can start its # response with this marker to suppress delivery. Output is still saved @@ -1975,6 +1976,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option _DEFAULT_SCRIPT_TIMEOUT = 3600 # seconds (1 hour) # Backward-compatible module override used by tests and emergency monkeypatches. _SCRIPT_TIMEOUT = _DEFAULT_SCRIPT_TIMEOUT +_RUN_CLAIM_HEARTBEAT_SECONDS = 60.0 def _get_script_timeout() -> int: @@ -2134,6 +2136,71 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: return False, f"Script execution failed: {exc}" +def _run_job_script_with_claim_heartbeat( + job: dict, script_path: str +) -> tuple[bool, str]: + """Run a cron script while keeping its owned one-shot claim fresh. + + Script execution is synchronous and may legitimately outlive the stale + claim TTL. Without a concurrent heartbeat, another scheduler process can + mistake the live run for a dead owner and dispatch the same one-shot again. + Recurring jobs and unclaimed/manual runs have no durable one-shot claim and + therefore use the ordinary script path without starting a thread. + + The claim owner is captured from the dispatched job and never re-read from + storage. ``heartbeat_run_claim`` compares that stable owner before every + refresh, so a stale runner cannot extend a replacement owner's claim. + """ + schedule = job.get("schedule") + claim = job.get("run_claim") + owner = str(claim.get("by") or "") if isinstance(claim, dict) else "" + if not ( + isinstance(schedule, dict) + and schedule.get("kind") == "once" + and owner + ): + return _run_job_script(script_path) + + job_id = str(job.get("id") or "") + stop = threading.Event() + heartbeat_context = contextvars.copy_context() + + def _heartbeat_loop() -> None: + while not stop.wait(_RUN_CLAIM_HEARTBEAT_SECONDS): + try: + heartbeat_run_claim(job_id, expected_owner=owner) + except Exception: + logger.debug( + "Job '%s': script run_claim heartbeat failed", + job_id, + exc_info=True, + ) + + heartbeat_thread = threading.Thread( + target=heartbeat_context.run, + args=(_heartbeat_loop,), + name="cron-script-claim-heartbeat", + daemon=True, + ) + try: + heartbeat_thread.start() + except Exception: + logger.debug( + "Job '%s': could not start script run_claim heartbeat", + job_id, + exc_info=True, + ) + return _run_job_script(script_path) + + try: + return _run_job_script(script_path) + finally: + stop.set() + # Event.wait() wakes immediately. Keep completion bounded if the + # heartbeat is already waiting on another process's jobs-file lock. + heartbeat_thread.join(timeout=1.0) + + def _parse_wake_gate(script_output: str) -> bool: """Parse the last non-empty stdout line of a cron job's pre-check script as a wake gate. @@ -2213,7 +2280,8 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: # Inject output from referenced cron jobs as context. context_from = job.get("context_from") if context_from: - from cron.jobs import OUTPUT_DIR + from cron.jobs import get_cron_output_dir + output_dir = get_cron_output_dir() if isinstance(context_from, str): context_from = [context_from] for source_job_id in context_from: @@ -2228,7 +2296,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: ) continue try: - job_output_dir = OUTPUT_DIR / source_job_id + job_output_dir = output_dir / source_job_id if not job_output_dir.exists(): continue # silent skip — no output yet output_files = sorted( @@ -2540,7 +2608,7 @@ def run_job( _prior_cwd = None try: - ok, output = _run_job_script(script_path) + ok, output = _run_job_script_with_claim_heartbeat(job, script_path) finally: if _prior_cwd is not None: try: @@ -2615,10 +2683,68 @@ def run_job( # Initialize SQLite session store so cron job messages are persisted # and discoverable via session_search (same pattern as gateway/run.py). + # + # Bounded with its own timeout (separate from HERMES_CRON_TIMEOUT, which + # only watches the agent's run_conversation below): SessionDB.__init__ + # opens/migrates state.db synchronously and has no timeout of its own + # against a wedged sqlite3.connect (e.g. a stale flock left by a crashed + # sibling process). An unbounded hang here is invisible to every other + # cron safeguard, because it happens BEFORE _submit_with_guard's future + # exists — the finally block that releases the job from + # _running_job_ids never runs, so the job stays wedged "running" until + # the whole gateway process is restarted, silently skipping every + # scheduled fire in between with "already running — skipping". _session_db = None try: from hermes_state import SessionDB - _session_db = SessionDB() + + # Resolve timeout: env override → config.yaml → default 10s. + # Mirrors the script_timeout_seconds resolution pattern. + _session_db_timeout: float | None = None + _raw_env_timeout = os.getenv("HERMES_CRON_SESSION_DB_TIMEOUT", "").strip() + if _raw_env_timeout: + try: + _session_db_timeout = float(_raw_env_timeout) + except (ValueError, TypeError): + logger.warning( + "Invalid HERMES_CRON_SESSION_DB_TIMEOUT=%r; using config/default", + _raw_env_timeout, + ) + if _session_db_timeout is None: + try: + from hermes_cli.config import load_config + _cfg = load_config() or {} + _cron_cfg = _cfg.get("cron", {}) if isinstance(_cfg, dict) else {} + _configured = _cron_cfg.get("session_db_timeout_seconds") + if _configured is not None: + _session_db_timeout = float(_configured) + except Exception as exc: + logger.debug( + "Failed to load cron.session_db_timeout_seconds from config: %s", + exc, + ) + if _session_db_timeout is None: + _session_db_timeout = 10.0 + + if _session_db_timeout > 0: + _session_db_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) + try: + _session_db = _session_db_pool.submit(SessionDB).result(timeout=_session_db_timeout) + finally: + # Don't wait for a wedged connect() to unwind — abandon the + # worker thread (same pattern as the agent inactivity timeout + # further down) rather than blocking shutdown on it too. + _session_db_pool.shutdown(wait=False) + else: + # 0 = unlimited (legacy behavior, opt-in for debugging) + _session_db = SessionDB() + except concurrent.futures.TimeoutError: + logger.error( + "Job '%s': SessionDB init did not return within %.0fs — proceeding " + "without a session store for this run instead of blocking it " + "forever", + job.get("id", "?"), _session_db_timeout, + ) except Exception as e: logger.debug("Job '%s': SQLite session store not available: %s", job.get("id", "?"), e) @@ -2629,7 +2755,7 @@ def run_job( prerun_script = None script_path = job.get("script") if script_path: - prerun_script = _run_job_script(script_path) + prerun_script = _run_job_script_with_claim_heartbeat(job, script_path) _ran_ok, _script_output = prerun_script if _ran_ok and not _parse_wake_gate(_script_output): logger.info( @@ -2862,11 +2988,12 @@ def run_job( except Exception: pass - # Reasoning config from config.yaml (raw value — a YAML boolean False - # means thinking disabled, see parse_reasoning_effort) - from hermes_constants import parse_reasoning_effort - reasoning_config = parse_reasoning_effort( - _cfg.get("agent", {}).get("reasoning_effort", "") + # Reasoning config from config.yaml (per-model override > global) — + # resolved through the shared chokepoint against the job's effective + # model (per-job override > HERMES_MODEL env > config.yaml default). + from hermes_constants import resolve_reasoning_config + reasoning_config = resolve_reasoning_config( + _cfg if isinstance(_cfg, dict) else {}, str(model) ) # Prefill messages from env or config.yaml. The top-level @@ -3096,6 +3223,38 @@ def run_job( _cron_timeout = 600.0 _cron_inactivity_limit = _cron_timeout if _cron_timeout > 0 else None _POLL_INTERVAL = 5.0 + # Keep the one-shot run_claim fresh while the run is alive (#62002): + # the claim TTL is a dead-owner detector, but without a heartbeat a + # run that legitimately outlives it (stream stall, laptop asleep + # mid-run) is indistinguishable from a dead tick — another process + # re-dispatches it and get_due_jobs stale-removes the job record out + # from under the live run. Refreshing the claim from this monitor + # keeps "expired claim" meaning "owner died". + _job_schedule = job.get("schedule") + _is_oneshot = ( + isinstance(_job_schedule, dict) and _job_schedule.get("kind") == "once" + ) + _run_claim = job.get("run_claim") + _run_claim_owner = ( + str(_run_claim.get("by") or "") if isinstance(_run_claim, dict) else "" + ) + _last_claim_heartbeat = time.monotonic() + + def _heartbeat_run_claim_if_due(): + nonlocal _last_claim_heartbeat + if not _is_oneshot or not _run_claim_owner: + return + _mono = time.monotonic() + if _mono - _last_claim_heartbeat < _RUN_CLAIM_HEARTBEAT_SECONDS: + return + _last_claim_heartbeat = _mono + try: + heartbeat_run_claim(job_id, expected_owner=_run_claim_owner) + except Exception: + logger.debug( + "Job '%s': run_claim heartbeat failed", job_name, exc_info=True + ) + _cron_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) # Preserve scheduler-scoped ContextVar state (for example skill-declared # env passthrough registrations) when the cron run hops into the worker @@ -3105,8 +3264,20 @@ def run_job( _inactivity_timeout = False try: if _cron_inactivity_limit is None: - # Unlimited — just wait for the result. - result = _cron_future.result() + # Unlimited — no inactivity watchdog, but a one-shot still + # needs its run_claim heartbeat, so poll instead of blocking. + if _is_oneshot: + result = None + while True: + done, _ = concurrent.futures.wait( + {_cron_future}, timeout=_POLL_INTERVAL, + ) + if done: + result = _cron_future.result() + break + _heartbeat_run_claim_if_due() + else: + result = _cron_future.result() else: result = None while True: @@ -3116,6 +3287,7 @@ def run_job( if done: result = _cron_future.result() break + _heartbeat_run_claim_if_due() # Agent still running — check inactivity. _idle_secs = 0.0 if hasattr(agent, "get_activity_summary"): @@ -3509,7 +3681,14 @@ def _notify_provider_jobs_changed() -> None: logger.debug("on_jobs_changed notify failed: %s", e) -def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> int: +def tick( + verbose: bool = True, + adapters=None, + loop=None, + sync: bool = True, + *, + can_dispatch=None, +): """ Check and run all due jobs. @@ -3520,7 +3699,9 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i verbose: Whether to print status messages adapters: Optional dict mapping Platform → live adapter (from gateway) loop: Optional asyncio event loop (from gateway) for live adapter sends - + can_dispatch: Optional synchronous gate; false leaves due jobs untouched + for the next allowed tick + Returns: Number of jobs executed (0 if another tick is already running) """ @@ -3542,6 +3723,10 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i return 0 try: + if can_dispatch is not None and not can_dispatch(): + logger.debug("Cron dispatch paused while gateway drains existing work") + return 0 + due_jobs = get_due_jobs() if verbose and not due_jobs: diff --git a/cron/scheduler_provider.py b/cron/scheduler_provider.py index ab3121bfa3ba..5429f79cf362 100644 --- a/cron/scheduler_provider.py +++ b/cron/scheduler_provider.py @@ -156,14 +156,16 @@ class InProcessCronScheduler(CronScheduler): ``start()`` blocks in the tick loop until ``stop_event`` is set, identical to the pre-refactor ``_start_cron_ticker`` core loop. The caller runs it in - a daemon thread. + a daemon thread. ``can_dispatch`` is an optional synchronous gate supplied + by GatewayRunner during external drain; skipped ticks leave due jobs intact + for the next allowed tick. """ @property def name(self) -> str: return "builtin" - def start(self, stop_event, *, adapters=None, loop=None, interval=60): + def start(self, stop_event, *, adapters=None, loop=None, interval=60, can_dispatch=None): import logging from cron.scheduler import tick as cron_tick from cron.jobs import record_ticker_heartbeat @@ -176,7 +178,16 @@ class InProcessCronScheduler(CronScheduler): while not stop_event.is_set(): ok = False try: - cron_tick(verbose=False, adapters=adapters, loop=loop, sync=False) + if can_dispatch is not None and not can_dispatch(): + logger.debug("Cron dispatch paused while gateway drains existing work") + else: + cron_tick( + verbose=False, + adapters=adapters, + loop=loop, + sync=False, + can_dispatch=can_dispatch, + ) ok = True except BaseException as e: # Catch BaseException (not just Exception) so a SystemExit from diff --git a/docs/profile-routing.md b/docs/profile-routing.md new file mode 100644 index 000000000000..9b0237f5c6f0 --- /dev/null +++ b/docs/profile-routing.md @@ -0,0 +1,117 @@ +# Profile-Based Routing for Inbound Messages + +> **Audience:** Gateway operators and contributors +> **Source files:** `gateway/profile_routing.py`, `gateway/run.py` (`_profile_name_for_source`), `gateway/platforms/base.py` (`build_source`), `gateway/config.py` +> **Related:** [Session Lifecycle](session-lifecycle.md), `docs/design/profile-builder.md` + +## Overview + +By default a single gateway run uses one profile (memory, persona, tools). **Profile-based +routing** lets one gateway instance serve **multiple isolated profiles**, selecting which +profile handles an inbound message based on *where the message came from* — the platform, +server (`guild_id`), channel (`chat_id`), and/or thread (`thread_id`). + +This is the inbound counterpart to multiplexing: instead of running N gateways, run one +gateway and route per-community / per-channel / per-thread to a dedicated profile. Each +profile keeps fully isolated state (`MEMORY.md`, `USER.md`, `SOUL.md`, sessions, tools). + +Routing is **platform-generic**: it works for Discord, Telegram, Feishu, Slack, and every +adapter — not just Discord. + +## Configuring routes + +Routes live under `profile_routes` in `config.yaml`. Both the top-level and the nested +`gateway.profile_routes` forms are accepted (the nested form is what +`hermes config set gateway.profile_routes ...` writes). + +```yaml +profile_routes: + # Route an entire Discord server (guild) to one profile. + - name: server-default + platform: discord + guild_id: "1234567890" + profile: server-profile + + # Override a specific channel within that server with a different profile. + - name: support-channel + platform: discord + guild_id: "1234567890" + chat_id: "9876543210" + profile: support-profile + + # Pin a Telegram group to a profile (Telegram has no guild_id — chat_id only). + - name: tg-group + platform: telegram + chat_id: "-1001234567890" + profile: tg-profile + + # Route a single Discord thread. + - name: standup-thread + platform: discord + guild_id: "1234567890" + chat_id: "9876543210" + thread_id: "1111111111" + profile: standup +``` + +### Fields + +| Field | Required | Description | +|---|---|---| +| `name` | yes | Human-readable route identifier (used in logs). | +| `platform` | yes | Adapter platform: `discord`, `telegram`, `feishu`, `slack`, … | +| `profile` | yes | Target profile name (must exist under `~/.hermes/profiles/<name>`). | +| `guild_id` | no | Server/guild (Discord). | +| `chat_id` | no | Channel/group/DM id. | +| `thread_id` | no | Thread id within a channel. | +| `enabled` | no | Default `true`; set `false` to disable a route without removing it. | + +## Matching rules + +A route matches an inbound source when **every discriminator the route declares is satisfied** +(conjunctive / AND). A field the route leaves unset is ignored. + +- **`platform`** must equal the source platform exactly. +- **`thread_id`** (if set) must equal the source thread id. +- **`chat_id`** (if set) must match the source channel **or** its parent — a thread in a + channel matches the channel's route (hierarchical match for Discord forums/threads). +- **`guild_id`** (if set) must equal the source guild. + +> A route declaring **both** `guild_id` and `chat_id` requires both to hold. A channel match +> alone does not satisfy a guild constraint — this is intentional and tested. + +When multiple routes match, the **most specific** one wins. Specificity is additive: + +| Discriminator | Weight | +|---|---| +| `thread_id` | 8 | +| `chat_id` | 4 | +| `guild_id` | 2 | +| (platform only) | 0 | + +So a thread route (8) beats a channel route (4) beats a guild route (2) within the same server. +If no route matches, the message uses the default/active profile. + +## How it works at runtime + +1. An inbound message arrives at a platform adapter. +2. `BasePlatformAdapter.build_source` builds the `SessionSource` for the message. Every + adapter carries a back-reference to the running `GatewayRunner` + (`gateway_runner`, injected in `gateway/run.py`), so it asks the runner to resolve the + target profile via `_profile_name_for_source`. +3. `_profile_name_for_source` runs the configured routes through `match_profile_route` and + stamps `source.profile` with the winning route's profile (or leaves it unset). +4. Downstream, `_resolve_profile_home_for_source` chooses the profile home directory + (`source.profile` → active profile → `default`) and the session is scoped per-profile, so + each routed community gets isolated memory and conversation state. + +Because `gateway_runner` is injected for **all** adapters (declared on `BasePlatformAdapter`), +every platform goes through this path — not just Discord. + +## Relationship to multiplexing + +`profile_routes` requires `gateway.multiplex_profiles: true`. Multiplexing is what +activates the per-profile runtime scope (per-profile `HERMES_HOME`, secret scope, and +profile-namespaced session keys); routing is the decision layer that picks *which* +profile a given guild/channel/thread lands in. With multiplexing off, `profile_routes` +is ignored entirely — behavior is byte-identical to a single-profile gateway. diff --git a/docs/relay-connector-contract.md b/docs/relay-connector-contract.md index 30646bf59d16..1c9c01ed1e10 100644 --- a/docs/relay-connector-contract.md +++ b/docs/relay-connector-contract.md @@ -51,6 +51,7 @@ JSON object. Source of truth: `gateway/relay/descriptor.py`. | `emoji` | string | no | Display emoji (default 🔌). | | `platform_hint` | string | no | System-prompt platform hint. | | `pii_safe` | bool | no | Redact PII in session descriptions. | +| `supports_context` | bool | no | Whether the connector can supply surrounding channel/group **context** for an addressed turn on this platform (Model A on-demand history fetch — Discord/Slack/Matrix; Model B passive buffer — Telegram/Signal/WhatsApp). Default false ⇒ no `context` is attached to inbound events. See §3. | Most fields are a projection of the gateway's existing `PlatformEntry`; the runtime-only fields (`len_unit`, `supports_*`, `markdown_dialect`) come from the @@ -95,6 +96,24 @@ Frames (connector → gateway, over the WS): - `{"type":"interrupt_inbound", "session_key", "chat_id"}` (§5) - `{"type":"passthrough_forward", "forward": <PassthroughForward>, "bufferId"?}` (§5.1) +**Channel context on inbound (design relay-channel-context).** When the source +platform's descriptor advertised `supports_context` (§2) and the chat is +multi-party (`chat_type` ∈ group/channel/thread/forum, never `dm`), the +connector MAY attach two optional, additive fields to the inbound `MessageEvent`: + +- `context`: an array of read-only surrounding messages (same channel, oldest→ + newest) — nearby non-addressed chatter the connector fetched (Model A) or + buffered (Model B). REFERENCE ONLY: it never triggers the agent (the trigger + decision was already made connector-side on the addressed event alone). The + gateway renders it into `MessageEvent.channel_context` (the same read-only + injection path history-backfill uses). +- `context_error`: bool, true when the platform is context-capable but the + fetch/buffer failed and the connector fail-opened to an empty `context` + (observability marker; surfaced connector-side via the delivery span). + +Both absent ⇒ byte-identical to today. A connector that never sends them, or a +`dm`, or a no-context platform, yields no `channel_context`. + `PassthroughForward` is the wire form of a forwarded passthrough-plane request (Class-2/3 webhooks — Discord interactions, Twilio): `{platform, botId, method, path, headers: [[k,v],…], bodyB64}`. The body is base64-encoded so arbitrary diff --git a/eslint.config.shared.mjs b/eslint.config.shared.mjs new file mode 100644 index 000000000000..d20f00b511a1 --- /dev/null +++ b/eslint.config.shared.mjs @@ -0,0 +1,112 @@ +/** + * Shared ESLint flat config for all Hermes TS workspaces. + * + * Usage in a workspace's eslint.config.mjs: + * + * import config from '../../eslint.config.shared.mjs' + * + * export default [ + * ...config, + * // workspace-specific overrides here + * ] + */ + +import js from '@eslint/js' +import typescriptEslint from '@typescript-eslint/eslint-plugin' +import typescriptParser from '@typescript-eslint/parser' +import perfectionist from 'eslint-plugin-perfectionist' +import reactPlugin from 'eslint-plugin-react' +import hooksPlugin from 'eslint-plugin-react-hooks' +import unusedImports from 'eslint-plugin-unused-imports' +import globals from 'globals' + +export default [ + { + ignores: ['**/node_modules/**', '**/dist/**', 'src/**/*.js', '**/package-lock.json'] + }, + js.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + globals: { + ...globals.node + }, + parser: typescriptParser, + parserOptions: { + ecmaFeatures: { jsx: true }, + ecmaVersion: 'latest', + sourceType: 'module' + } + }, + plugins: { + '@typescript-eslint': typescriptEslint, + perfectionist, + react: reactPlugin, + 'react-hooks': hooksPlugin, + 'unused-imports': unusedImports + }, + rules: { + '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], + '@typescript-eslint/no-unused-vars': 'off', + curly: ['error', 'all'], + 'no-fallthrough': ['error', { allowEmptyCase: true }], + 'no-undef': 'off', + 'no-unused-vars': 'off', + 'padding-line-between-statements': [ + 1, + { + blankLine: 'always', + next: [ + 'block-like', + 'block', + 'return', + 'if', + 'class', + 'continue', + 'debugger', + 'break', + 'multiline-const', + 'multiline-let' + ], + prev: '*' + }, + { + blankLine: 'always', + next: '*', + prev: ['case', 'default', 'multiline-const', 'multiline-let', 'multiline-block-like'] + }, + { blankLine: 'never', next: ['block', 'block-like'], prev: ['case', 'default'] }, + { blankLine: 'always', next: ['block', 'block-like'], prev: ['block', 'block-like'] }, + { blankLine: 'always', next: ['empty'], prev: 'export' }, + { blankLine: 'never', next: 'iife', prev: ['block', 'block-like', 'empty'] } + ], + 'perfectionist/sort-exports': ['error', { order: 'asc', type: 'natural' }], + 'perfectionist/sort-imports': [ + 'error', + { + groups: ['side-effect', 'builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + order: 'asc', + type: 'natural' + } + ], + 'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }], + 'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }], + 'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }], + 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/rules-of-hooks': 'error', + 'unused-imports/no-unused-imports': 'error' + }, + settings: { + react: { version: 'detect' } + } + }, + { + files: ['**/*.js', '**/*.cjs', '**/*.mjs'], + ignores: ['**/node_modules/**', '**/dist/**'], + languageOptions: { + ecmaVersion: 'latest', + globals: { ...globals.node }, + sourceType: 'module' + } + } +] diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 0a9a2efddf84..ff207d86cb35 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -6,6 +6,7 @@ Built on gateway startup, refreshed periodically (every 5 min), and saved to action="list" and for resolving human-friendly channel names to numeric IDs. """ +import asyncio import json import logging from datetime import datetime @@ -121,7 +122,7 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: for platform, adapter in adapters.items(): try: if platform == Platform.DISCORD: - platforms["discord"] = _build_discord(adapter) + platforms["discord"] = await asyncio.to_thread(_build_discord, adapter) elif platform == Platform.SLACK: platforms["slack"] = await _build_slack(adapter) except Exception as e: @@ -142,7 +143,7 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: or plat_name not in adapter_platform_names ): continue - platforms[plat_name] = _build_from_sessions(plat_name) + platforms[plat_name] = await asyncio.to_thread(_build_from_sessions, plat_name) # Include plugin-registered platforms (dynamic enum members aren't in # Platform.__members__, so the loop above misses them). Same @@ -156,7 +157,7 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: and entry.name not in platforms and entry.name in adapter_platform_names ): - platforms[entry.name] = _build_from_sessions(entry.name) + platforms[entry.name] = await asyncio.to_thread(_build_from_sessions, entry.name) except Exception: pass @@ -223,7 +224,7 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]: """ team_clients = getattr(adapter, "_team_clients", None) or {} if not team_clients: - return _build_from_sessions("slack") + return await asyncio.to_thread(_build_from_sessions, "slack") channels: List[Dict[str, Any]] = [] seen_ids: set = set() @@ -267,7 +268,7 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]: continue # Merge in DM/group entries discovered from session history. - for entry in _build_from_sessions("slack"): + for entry in await asyncio.to_thread(_build_from_sessions, "slack"): if entry.get("id") not in seen_ids: channels.append(entry) seen_ids.add(entry.get("id")) diff --git a/gateway/config.py b/gateway/config.py index 8c467196e3b5..71e4d1d3fced 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -12,7 +12,7 @@ import logging import os import json from pathlib import Path -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field, is_dataclass from typing import Dict, List, Optional, Any, Callable from enum import Enum @@ -130,6 +130,11 @@ def _coerce_optional_positive_int(value: Any, key: str) -> Optional[int]: return parsed +def _coerce_dict(value: Any) -> Dict[str, Any]: + """Return *value* when it is a mapping, otherwise an empty dict.""" + return value if isinstance(value, dict) else {} + + def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: """Normalize unauthorized DM behavior to a supported value.""" if isinstance(value, str): @@ -383,6 +388,7 @@ class SessionResetPolicy: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy": + data = _coerce_dict(data) # Handle both missing keys and explicit null values (YAML null → None) mode = data.get("mode") at_hour = data.get("at_hour") @@ -492,24 +498,26 @@ class PlatformConfig: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": + data = _coerce_dict(data) home_channel = None - if "home_channel" in data: + if isinstance(data.get("home_channel"), dict): home_channel = HomeChannel.from_dict(data["home_channel"]) # gateway_restart_notification may be bridged into extra via the # shared-key loop in load_gateway_config(); check both top-level # and extra so YAML ``discord: gateway_restart_notification: false`` # works without needing a separate platforms: block. + extra = _coerce_dict(data.get("extra", {})) _grn = data.get("gateway_restart_notification") if _grn is None: - _grn = data.get("extra", {}).get("gateway_restart_notification") + _grn = extra.get("gateway_restart_notification") # typing_indicator mirrors gateway_restart_notification: it may arrive # top-level or bridged into extra by the shared-key loop in # load_gateway_config(), so check both. _typing = data.get("typing_indicator") if _typing is None: - _typing = data.get("extra", {}).get("typing_indicator") + _typing = extra.get("typing_indicator") channel_overrides: Dict[str, ChannelOverride] = {} raw_overrides = data.get("channel_overrides") or {} @@ -527,7 +535,7 @@ class PlatformConfig: gateway_restart_notification=_coerce_bool(_grn, True), typing_indicator=_coerce_bool(_typing, True), channel_overrides=channel_overrides, - extra=data.get("extra", {}), + extra=extra, ) @@ -586,7 +594,7 @@ class StreamingConfig: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig": - if not data: + if not isinstance(data, dict) or not data: return cls() return cls( enabled=_coerce_bool(data.get("enabled"), False), @@ -713,6 +721,11 @@ class GatewayConfig: # fresh session exactly as if the reset policy had fired. 0 = disabled. session_store_max_age_days: int = 90 + # Profile-based routing: route specific guilds/channels/threads to + # different profiles. See gateway/profile_routing.py. Each entry is a + # dict with: name, platform, profile, and optional guild_id/chat_id/thread_id. + profile_routes: list = field(default_factory=list) + def get_connected_platforms(self) -> List[Platform]: """Return list of platforms that are enabled and configured.""" connected = [] @@ -819,12 +832,20 @@ class GatewayConfig: "unauthorized_dm_behavior": self.unauthorized_dm_behavior, "streaming": self.streaming.to_dict(), "session_store_max_age_days": self.session_store_max_age_days, + "profile_routes": [ + asdict(r) if is_dataclass(r) and not isinstance(r, type) else r + for r in self.profile_routes + ], } @classmethod def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": + data = _coerce_dict(data) platforms = {} - for platform_name, platform_data in data.get("platforms", {}).items(): + platforms_data = _coerce_dict(data.get("platforms", {})) + for platform_name, platform_data in platforms_data.items(): + if not isinstance(platform_data, dict): + continue try: platform = Platform(platform_name) platforms[platform] = PlatformConfig.from_dict(platform_data) @@ -832,11 +853,11 @@ class GatewayConfig: pass # Skip unknown platforms reset_by_type = {} - for type_name, policy_data in data.get("reset_by_type", {}).items(): + for type_name, policy_data in _coerce_dict(data.get("reset_by_type", {})).items(): reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data) reset_by_platform = {} - for platform_name, policy_data in data.get("reset_by_platform", {}).items(): + for platform_name, policy_data in _coerce_dict(data.get("reset_by_platform", {})).items(): try: platform = Platform(platform_name) reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data) @@ -907,6 +928,10 @@ class GatewayConfig: except (TypeError, ValueError): session_store_max_age_days = 90 + # Parse profile routes (validated by gateway.profile_routing) + from gateway.profile_routing import parse_profile_routes + profile_routes = parse_profile_routes(data.get("profile_routes") or []) + return cls( platforms=platforms, default_reset_policy=default_policy, @@ -929,6 +954,7 @@ class GatewayConfig: unauthorized_dm_behavior=unauthorized_dm_behavior, streaming=StreamingConfig.from_dict(data.get("streaming", {})), session_store_max_age_days=session_store_max_age_days, + profile_routes=profile_routes, ) def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str: @@ -1027,6 +1053,8 @@ def load_gateway_config() -> GatewayConfig: if "stt_echo_transcripts" in yaml_cfg: gw_data["stt_echo_transcripts"] = yaml_cfg["stt_echo_transcripts"] + gateway_cfg = yaml_cfg.get("gateway") + if "group_sessions_per_user" in yaml_cfg: gw_data["group_sessions_per_user"] = yaml_cfg["group_sessions_per_user"] @@ -1039,6 +1067,17 @@ def load_gateway_config() -> GatewayConfig: if "multiplex_profiles" in yaml_cfg: gw_data["multiplex_profiles"] = yaml_cfg["multiplex_profiles"] + # Profile-based routing rules: accept either top-level + # ``profile_routes`` or the nested ``gateway.profile_routes`` form + # (matching the multiplex_profiles parity above). + _pr = yaml_cfg.get("profile_routes") + if _pr is None: + _gw_section = yaml_cfg.get("gateway") + if isinstance(_gw_section, dict): + _pr = _gw_section.get("profile_routes") + if isinstance(_pr, list): + gw_data["profile_routes"] = _pr + gateway_section = yaml_cfg.get("gateway") if isinstance(gateway_section, dict): if "multiplex_profiles" in gateway_section and "multiplex_profiles" not in gw_data: @@ -1054,7 +1093,11 @@ def load_gateway_config() -> GatewayConfig: if not isinstance(streaming_cfg, dict): # Fall back to nested gateway.streaming written by # ``hermes config set gateway.streaming.*`` - streaming_cfg = yaml_cfg.get("gateway", {}).get("streaming") + streaming_cfg = ( + gateway_cfg.get("streaming") + if isinstance(gateway_cfg, dict) + else None + ) if isinstance(streaming_cfg, dict): gw_data["streaming"] = streaming_cfg @@ -1087,7 +1130,6 @@ def load_gateway_config() -> GatewayConfig: # ``gateway.platforms`` are loaded the same way as top-level # ``platforms``. Merge nested first so top-level config keeps # precedence, matching the existing gateway.streaming fallback. - gateway_cfg = yaml_cfg.get("gateway") gateway_platforms = gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None platforms_data = gw_data.setdefault("platforms", {}) if not isinstance(platforms_data, dict): diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 5ba09d67492e..6287132243ad 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -35,6 +35,9 @@ import asyncio import hashlib import hmac import json +from contextlib import contextmanager +from contextvars import ContextVar +from functools import wraps import logging import os import socket as _socket @@ -45,6 +48,12 @@ import uuid from pathlib import Path from typing import Any, Dict, List, Optional +def _approval_event_choices(*, smart_denied: bool, allow_permanent: bool) -> list[str]: + if smart_denied: + return ["once", "deny"] + return ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"] + + try: from aiohttp import web AIOHTTP_AVAILABLE = True @@ -61,6 +70,7 @@ from gateway.platforms.base import ( validate_media_delivery_path, ) from agent.redact import redact_sensitive_text +from gateway.readiness import collect_runtime_readiness logger = logging.getLogger(__name__) @@ -657,6 +667,66 @@ def _openai_error(message: str, err_type: str = "invalid_request_error", param: } +_api_agent_request_reservation: ContextVar[Optional[dict[str, bool]]] = ContextVar( + "api_agent_request_reservation", default=None +) + + +def _admit_api_agent_request(handler): + """Reserve an authenticated API turn before its handler first awaits. + + Gateway shutdown and aiohttp requests share an event loop. Keeping the + drain check and reservation in one non-awaiting block prevents a request + admitted immediately before shutdown from becoming invisible while it is + still parsing its body or resolving session state. The mutable reservation + is intentionally shared with child tasks so agent/task bookkeeping releases + this one slot exactly once. + """ + @wraps(handler) + async def _wrapped(self, request, *args, **kwargs): + auth_err = self._check_auth(request) + if auth_err: + return auth_err + draining = self._draining_response() + if draining is not None: + return draining + reservation = {"active": True} + token = _api_agent_request_reservation.set(reservation) + self._pending_agent_requests += 1 + try: + return await handler(self, request, *args, **kwargs) + finally: + if reservation["active"]: + reservation["active"] = False + self._pending_agent_requests = max(0, self._pending_agent_requests - 1) + _api_agent_request_reservation.reset(token) + + return _wrapped + + +def _release_pending_api_work(adapter, reservation: dict[str, bool]) -> None: + """Release a pending-work reservation exactly once.""" + if reservation["active"]: + reservation["active"] = False + adapter._pending_agent_requests = max(0, adapter._pending_agent_requests - 1) + + +@contextmanager +def _reserve_pending_api_work(adapter): + """Keep externally-triggered background work visible across awaits. + + A handler can detach the reservation to an asyncio task; its done callback + then owns release so shutdown cannot miss the handoff to background work. + """ + reservation = {"active": True, "detached": False} + adapter._pending_agent_requests += 1 + try: + yield reservation + finally: + if not reservation["detached"]: + _release_pending_api_work(adapter, reservation) + + if AIOHTTP_AVAILABLE: @web.middleware async def body_limit_middleware(request, handler): @@ -881,9 +951,13 @@ class APIServerAdapter(BasePlatformAdapter): self._run_streams: Dict[str, "asyncio.Queue[Optional[Dict]]"] = {} # Creation timestamps for orphaned-run TTL sweep self._run_streams_created: Dict[str, float] = {} + # Runs with a connected SSE consumer; their queue is actively draining. + self._run_stream_subscribers: set[str] = set() # Active run agent/task references for stop support self._active_run_agents: Dict[str, Any] = {} self._active_run_tasks: Dict[str, "asyncio.Task"] = {} + # Stop is cooperative: the executor thread may outlive the HTTP request. + self._stopping_run_ids: set[str] = set() # Pollable run status for dashboards and external control-plane UIs. self._run_statuses: Dict[str, Dict[str, Any]] = {} # Active approval session key for each run_id. The approval core @@ -898,8 +972,90 @@ class APIServerAdapter(BasePlatformAdapter): # from a request flood (#7483). self._max_concurrent_runs: int = self._resolve_max_concurrent_runs() # Number of in-flight runs on the non-streaming chat/responses paths - # (the /v1/runs path tracks its own in-flight set via _run_streams). + # (the /v1/runs path tracks its own in-flight set via + # _active_run_tasks). self._inflight_agent_runs: int = 0 + # Requests admitted before their handler reaches agent bookkeeping. + # Shutdown counts this reservation so the request cannot slip through + # the drain between its first await and _run_agent()/task registration. + self._pending_agent_requests: int = 0 + + def active_agent_work_count(self) -> int: + """Return all live agent work owned by this API adapter. + + ``/v1/runs`` registers an asyncio task before it constructs and stores + its agent, so ``_active_run_agents`` has a real queued-before-agent gap. + Reuse the task-based accounting used by the concurrent-run limit: it + covers that gap and excludes completed tasks retained until cleanup. + """ + try: + return ( + int(getattr(self, "_pending_agent_requests", 0)) + + int(self._inflight_agent_runs) + + sum(not task.done() for task in self._active_run_tasks.values()) + ) + except Exception: + return 0 + + @staticmethod + def _gateway_is_draining() -> bool: + """Whether the owning gateway currently refuses new agent turns.""" + try: + from gateway.run import _gateway_runner_ref + + runner = _gateway_runner_ref() + return bool( + runner + and ( + getattr(runner, "_draining", False) + or getattr(runner, "_external_drain_active", False) + ) + ) + except Exception: + return False + + def _draining_response(self) -> Optional["web.Response"]: + """Return a retryable response while the gateway drains existing work.""" + if not self._gateway_is_draining(): + return None + return web.json_response( + _openai_error( + "Gateway is draining existing work; retry shortly.", + code="gateway_draining", + ), + status=503, + headers={"Retry-After": "1"}, + ) + + def _activate_admitted_request(self) -> None: + """Transfer this request's drain reservation to agent bookkeeping.""" + reservation = _api_agent_request_reservation.get() + if reservation and reservation["active"]: + reservation["active"] = False + self._pending_agent_requests = max(0, self._pending_agent_requests - 1) + + def _readiness_work_counts(self) -> tuple[int, int, int]: + """Return bounded work counts from each subsystem's public state.""" + active_api_runs = sum( + 1 + for status in self._run_statuses.values() + if status.get("status") in {"queued", "running", "waiting_for_approval"} + ) + process_depth = 0 + active_delegations = 0 + try: + from tools.process_registry import process_registry + + process_depth = process_registry.completion_queue.qsize() + except Exception: + pass + try: + from tools.async_delegation import active_count + + active_delegations = active_count() + except Exception: + pass + return active_api_runs, process_depth, active_delegations @staticmethod def _parse_cors_origins(value: Any) -> tuple[str, ...]: @@ -1397,8 +1553,19 @@ class APIServerAdapter(BasePlatformAdapter): # This endpoint is served BY the gateway process, so it is by definition # alive — gateway_running is True. Derive busy/drainable from the same # shared contract /api/status uses so the two surfaces never disagree. + active_api_runs, process_depth, active_delegations = self._readiness_work_counts() + from gateway.run import _resolve_gateway_model + + readiness = collect_runtime_readiness( + configured_model=_resolve_gateway_model(), + runtime_status=runtime, + active_api_runs=active_api_runs, + process_completion_queue_depth=process_depth, + active_delegations=active_delegations, + ) return web.json_response({ - "status": "ok", + "status": readiness["status"], + "readiness": readiness, "platform": "hermes-agent", "version": _hermes_version(), "gateway_state": gw_state, @@ -1873,11 +2040,9 @@ class APIServerAdapter(BasePlatformAdapter): fork = db.get_session(fork_id) or {"id": fork_id, "parent_session_id": source_id} return web.json_response({"object": "hermes.session", "session": self._session_response(fork)}, status=201) + @_admit_api_agent_request async def _handle_session_chat(self, request: "web.Request") -> "web.Response": """POST /api/sessions/{session_id}/chat — one synchronous agent turn.""" - auth_err = self._check_auth(request) - if auth_err: - return auth_err gateway_session_key, key_err = self._parse_session_key_header(request) if key_err is not None: return key_err @@ -1917,11 +2082,9 @@ class APIServerAdapter(BasePlatformAdapter): headers=headers, ) + @_admit_api_agent_request async def _handle_session_chat_stream(self, request: "web.Request") -> "web.StreamResponse": """POST /api/sessions/{session_id}/chat/stream — SSE wrapper over _run_agent.""" - auth_err = self._check_auth(request) - if auth_err: - return auth_err gateway_session_key, key_err = self._parse_session_key_header(request) if key_err is not None: return key_err @@ -2058,12 +2221,9 @@ class APIServerAdapter(BasePlatformAdapter): logger.debug("[api_server] session SSE stream error: %s", exc) return response + @_admit_api_agent_request async def _handle_chat_completions(self, request: "web.Request") -> "web.Response": """POST /v1/chat/completions — OpenAI Chat Completions format.""" - auth_err = self._check_auth(request) - if auth_err: - return auth_err - # Bound total in-flight agent runs (configurable; #7483). limited = self._concurrency_limited_response() if limited is not None: @@ -2265,8 +2425,8 @@ class APIServerAdapter(BasePlatformAdapter): # ``tool_progress_callback`` is intentionally not wired here: # it would duplicate every emit because ``run_agent`` fires it # side-by-side with ``tool_start_callback``/``tool_complete_callback``. - # The structured callbacks are strictly richer (they carry the - # tool_call id), so they own the chat-completions SSE channel. + # The structured callbacks are strictly richer (they carry + # the tool_call id), so they own the chat-completions SSE channel. agent_ref = [None] agent_task = asyncio.ensure_future(self._run_agent( user_message=user_message, @@ -3194,12 +3354,9 @@ class APIServerAdapter(BasePlatformAdapter): return response + @_admit_api_agent_request async def _handle_responses(self, request: "web.Request") -> "web.Response": """POST /v1/responses — OpenAI Responses API format.""" - auth_err = self._check_auth(request) - if auth_err: - return auth_err - # Bound total in-flight agent runs (configurable; #7483). limited = self._concurrency_limited_response() if limited is not None: @@ -3742,6 +3899,9 @@ class APIServerAdapter(BasePlatformAdapter): auth_err = self._check_auth(request) if auth_err: return auth_err + draining = self._draining_response() + if draining is not None: + return draining cron_err = self._check_jobs_available() if cron_err: return cron_err @@ -3787,31 +3947,39 @@ class APIServerAdapter(BasePlatformAdapter): self._request_audit_log_suffix(request), ) return web.json_response({"error": "invalid fire token"}, status=401) + draining = self._draining_response() + if draining is not None: + return draining - try: - body = await request.json() - except Exception: - body = {} - job_id = (body or {}).get("job_id") - if not job_id: - return web.json_response({"error": "missing job_id"}, status=400) + with _reserve_pending_api_work(self) as reservation: + try: + body = await request.json() + except Exception: + body = {} + job_id = (body or {}).get("job_id") + if not job_id: + return web.json_response({"error": "missing job_id"}, status=400) - from cron.scheduler_provider import resolve_cron_scheduler - provider = resolve_cron_scheduler() + from cron.scheduler_provider import resolve_cron_scheduler + provider = resolve_cron_scheduler() - loop = asyncio.get_running_loop() - # Fire in the background (202 immediately). fire_due claims via the - # store CAS, so a retry while this is in flight is de-duped. - task = asyncio.create_task( - asyncio.to_thread(provider.fire_due, job_id, adapters=None, loop=loop) - ) - try: - self._background_tasks.add(task) - task.add_done_callback(self._background_tasks.discard) - except (TypeError, AttributeError): - pass + loop = asyncio.get_running_loop() + # Fire in the background (202 immediately). fire_due claims via the + # store CAS, so a retry while this is in flight is de-duped. + task = asyncio.create_task( + asyncio.to_thread(provider.fire_due, job_id, adapters=None, loop=loop) + ) + reservation["detached"] = True + task.add_done_callback( + lambda _task: _release_pending_api_work(self, reservation) + ) + try: + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + except (TypeError, AttributeError): + pass - return web.json_response({"status": "accepted", "job_id": job_id}, status=202) + return web.json_response({"status": "accepted", "job_id": job_id}, status=202) # ------------------------------------------------------------------ @@ -3965,15 +4133,22 @@ class APIServerAdapter(BasePlatformAdapter): """Return a 429 response if the concurrent-run cap is reached, else None. The cap bounds total in-flight agent activity across every - agent-serving endpoint: the non-streaming chat/responses paths - (tracked by ``_inflight_agent_runs``) plus the ``/v1/runs`` streaming - path (tracked by ``_run_streams``). A configured value of 0 disables - the cap entirely. + agent-serving endpoint. Reuse the same adapter-owned work count that + shutdown draining uses, including an admitted request before it reaches + agent/task bookkeeping. Stream queues are transport state and may + disappear while their underlying run remains active, so they must not + define run concurrency. A configured value of 0 disables the cap. """ limit = self._max_concurrent_runs if limit <= 0: return None - inflight = self._inflight_agent_runs + len(self._run_streams) + inflight = self.active_agent_work_count() + # The current request owns one reservation until it hands off to + # _run_agent() or /v1/runs task registration. It must not consume its + # own last available slot; other admitted requests remain counted. + reservation = _api_agent_request_reservation.get() + if reservation and reservation["active"]: + inflight -= 1 if inflight >= limit: return web.json_response( _openai_error( @@ -4091,6 +4266,7 @@ class APIServerAdapter(BasePlatformAdapter): finally: clear_session_vars(tokens) + self._activate_admitted_request() self._inflight_agent_runs += 1 try: return await loop.run_in_executor(None, _run) @@ -4165,12 +4341,9 @@ class APIServerAdapter(BasePlatformAdapter): return _callback + @_admit_api_agent_request async def _handle_runs(self, request: "web.Request") -> "web.Response": """POST /v1/runs — start an agent run, return run_id immediately.""" - auth_err = self._check_auth(request) - if auth_err: - return auth_err - # Long-term memory scope header (see chat_completions for details). gateway_session_key, key_err = self._parse_session_key_header(request) if key_err is not None: @@ -4260,12 +4433,19 @@ class APIServerAdapter(BasePlatformAdapter): event_cb = self._make_run_event_callback(run_id, loop) + def _put_event_if_active(event: Optional[Dict]) -> None: + """Enqueue only while this run still owns live transport state.""" + if self._run_streams.get(run_id) is q: + q.put_nowait(event) + # Also wire stream_delta_callback so message.delta events flow through. def _text_cb(delta: Optional[str]) -> None: if delta is None: return + if run_id not in self._run_streams: + return try: - loop.call_soon_threadsafe(q.put_nowait, { + loop.call_soon_threadsafe(_put_event_if_active, { "event": "message.delta", "run_id": run_id, "timestamp": time.time(), @@ -4288,6 +4468,18 @@ class APIServerAdapter(BasePlatformAdapter): async def _run_and_close(): try: self._set_run_status(run_id, "running") + if run_id in self._stopping_run_ids: + _put_event_if_active({ + "event": "run.cancelled", + "run_id": run_id, + "timestamp": time.time(), + }) + self._set_run_status( + run_id, + "cancelled", + last_event="run.cancelled", + ) + return agent = self._create_agent( ephemeral_system_prompt=ephemeral_system_prompt, session_id=session_id, @@ -4312,7 +4504,10 @@ class APIServerAdapter(BasePlatformAdapter): "event": "approval.request", "run_id": run_id, "timestamp": time.time(), - "choices": ["once", "session", "always", "deny"], + "choices": _approval_event_choices( + smart_denied=bool(event.get("smart_denied")), + allow_permanent=event.get("allow_permanent") is not False, + ), }) self._set_run_status( run_id, @@ -4372,12 +4567,23 @@ class APIServerAdapter(BasePlatformAdapter): return r, u result, usage = await asyncio.get_running_loop().run_in_executor(None, _run_sync) + if run_id in self._stopping_run_ids: + _put_event_if_active({ + "event": "run.cancelled", + "run_id": run_id, + "timestamp": time.time(), + }) + self._set_run_status( + run_id, + "cancelled", + last_event="run.cancelled", + ) # Check for structured failure (non-retryable client errors like # 401/400 return failed=True instead of raising, so the except # block below never fires — issue #15561). - if isinstance(result, dict) and result.get("failed"): + elif isinstance(result, dict) and result.get("failed"): error_msg = _redact_api_error_text(result.get("error") or "agent run failed") - q.put_nowait({ + _put_event_if_active({ "event": "run.failed", "run_id": run_id, "timestamp": time.time(), @@ -4391,7 +4597,7 @@ class APIServerAdapter(BasePlatformAdapter): ) else: final_response = result.get("final_response", "") if isinstance(result, dict) else "" - q.put_nowait({ + _put_event_if_active({ "event": "run.completed", "run_id": run_id, "timestamp": time.time(), @@ -4412,7 +4618,7 @@ class APIServerAdapter(BasePlatformAdapter): last_event="run.cancelled", ) try: - q.put_nowait({ + _put_event_if_active({ "event": "run.cancelled", "run_id": run_id, "timestamp": time.time(), @@ -4429,7 +4635,7 @@ class APIServerAdapter(BasePlatformAdapter): last_event="run.failed", ) try: - q.put_nowait({ + _put_event_if_active({ "event": "run.failed", "run_id": run_id, "timestamp": time.time(), @@ -4451,13 +4657,15 @@ class APIServerAdapter(BasePlatformAdapter): pass # Sentinel: signal SSE stream to close try: - q.put_nowait(None) + _put_event_if_active(None) except Exception: pass self._active_run_agents.pop(run_id, None) self._active_run_tasks.pop(run_id, None) self._run_approval_sessions.pop(run_id, None) + self._stopping_run_ids.discard(run_id) + self._activate_admitted_request() task = asyncio.create_task(_run_and_close()) self._active_run_tasks[run_id] = task try: @@ -4508,6 +4716,7 @@ class APIServerAdapter(BasePlatformAdapter): return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404) q = self._run_streams[run_id] + self._run_stream_subscribers.add(run_id) response = web.StreamResponse( status=200, @@ -4535,6 +4744,7 @@ class APIServerAdapter(BasePlatformAdapter): except Exception as exc: logger.debug("[api_server] SSE stream error for run %s: %s", run_id, exc) finally: + self._run_stream_subscribers.discard(run_id) self._run_streams.pop(run_id, None) self._run_streams_created.pop(run_id, None) @@ -4643,6 +4853,7 @@ class APIServerAdapter(BasePlatformAdapter): return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404) self._set_run_status(run_id, "stopping", last_event="run.stopping") + self._stopping_run_ids.add(run_id) if agent is not None: try: @@ -4650,37 +4861,29 @@ class APIServerAdapter(BasePlatformAdapter): except Exception: pass - if task is not None and not task.done(): - task.cancel() - # Bounded wait: run_conversation() executes in the default - # executor thread which task.cancel() cannot preempt — we rely on - # agent.interrupt() above to break the loop. Cap the wait so a - # slow/unresponsive interrupt can't hang this handler. - try: - await asyncio.wait_for(asyncio.shield(task), timeout=5.0) - except asyncio.TimeoutError: - logger.warning( - "[api_server] stop for run %s timed out after 5s; " - "agent may still be finishing the current step", - run_id, - ) - except (asyncio.CancelledError, Exception): - pass - return web.json_response({"run_id": run_id, "status": "stopping"}) async def _sweep_orphaned_runs(self) -> None: - """Periodically clean up run streams that were never consumed.""" + """Periodically expire transport buffers and terminal status records.""" while True: await asyncio.sleep(60) + self._sweep_orphaned_runs_once(time.time()) + + def _sweep_orphaned_runs_once(self, now: Optional[float] = None) -> None: + """Expire old SSE buffers without treating transport age as run age.""" + if now is None: now = time.time() - stale = [ - run_id - for run_id, created_at in list(self._run_streams_created.items()) - if now - created_at > self._RUN_STREAM_TTL - ] - for run_id in stale: - logger.debug("[api_server] sweeping orphaned run %s", run_id) + stale = [ + run_id + for run_id, created_at in list(self._run_streams_created.items()) + if now - created_at > self._RUN_STREAM_TTL + and run_id not in self._run_stream_subscribers + ] + for run_id in stale: + logger.debug("[api_server] sweeping expired run transport %s", run_id) + task = self._active_run_tasks.get(run_id) + task_done = task is None or task.done() + if task_done: try: from tools.approval import unregister_gateway_notify @@ -4689,20 +4892,24 @@ class APIServerAdapter(BasePlatformAdapter): unregister_gateway_notify(approval_session_key) except Exception: pass - self._run_streams.pop(run_id, None) - self._run_streams_created.pop(run_id, None) + # The transport TTL always bounds buffering. Live control state is + # independent and survives until the executor-backed task returns. + self._run_streams.pop(run_id, None) + self._run_streams_created.pop(run_id, None) + if task_done: self._active_run_agents.pop(run_id, None) self._active_run_tasks.pop(run_id, None) self._run_approval_sessions.pop(run_id, None) + self._stopping_run_ids.discard(run_id) - stale_statuses = [ - run_id - for run_id, status in list(self._run_statuses.items()) - if status.get("status") in {"completed", "failed", "cancelled"} - and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL - ] - for run_id in stale_statuses: - self._run_statuses.pop(run_id, None) + stale_statuses = [ + run_id + for run_id, status in list(self._run_statuses.items()) + if status.get("status") in {"completed", "failed", "cancelled"} + and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL + ] + for run_id in stale_statuses: + self._run_statuses.pop(run_id, None) # ------------------------------------------------------------------ # BasePlatformAdapter interface diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 21ca4af6bb4d..d3c935733e6b 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -63,9 +63,17 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None) ``direct_messages_topic_id`` when the Bot API supports it. """ thread_id = getattr(source, "thread_id", None) - if thread_id is None: + metadata = {"thread_id": thread_id} if thread_id is not None else {} + # Slack workspace identity is durable routing state, not ephemeral event + # metadata. Carry it on every outbound path (including unthreaded sends) + # so a multi-workspace Socket Mode gateway never falls back to its primary + # WebClient after an async, stream, or recovery boundary. + if _platform_name(getattr(source, "platform", None)) == "slack": + scope_id = getattr(source, "scope_id", None) + if scope_id: + metadata["slack_team_id"] = str(scope_id) + if not metadata: return None - metadata = {"thread_id": thread_id} if _platform_name(getattr(source, "platform", None)) == "telegram" and getattr(source, "chat_type", None) == "dm": metadata["telegram_dm_topic_reply_fallback"] = True tid = str(thread_id) @@ -1072,10 +1080,33 @@ def _profile_cache_roots() -> List[Path]: return roots +def _kanban_attachment_roots() -> List[Path]: + """Return durable Kanban attachment roots without importing kanban_db.""" + override = os.environ.get("HERMES_KANBAN_ATTACHMENTS_ROOT", "").strip() + if override: + return [Path(override).expanduser()] + home_override = os.environ.get("HERMES_KANBAN_HOME", "").strip() + root = Path(home_override).expanduser() if home_override else _HERMES_ROOT + roots = [root / "kanban" / "attachments"] + boards_root = root / "kanban" / "boards" + try: + board_dirs = [ + path for path in boards_root.iterdir() + if path.is_dir() and not path.is_symlink() + and re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", path.name) + and (path / "kanban.db").is_file() + ] + except OSError: + return roots + roots.extend(path / "attachments" for path in board_dirs) + return roots + + def _media_delivery_allowed_roots() -> List[Path]: """Return roots from which model-emitted local media may be delivered.""" roots = [Path(root) for root in MEDIA_DELIVERY_SAFE_ROOTS] roots.extend(_profile_cache_roots()) + roots.extend(_kanban_attachment_roots()) extra_roots = os.environ.get(MEDIA_DELIVERY_ALLOW_DIRS_ENV, "") for chunk in extra_roots.split(os.pathsep): for raw_root in chunk.split(","): @@ -2322,6 +2353,16 @@ class BasePlatformAdapter(ABC): # generic seam; Slack is merely the first consumer). supports_inchannel_continuable: bool = False + # Back-reference to the running ``GatewayRunner``, injected by + # ``gateway/run.py`` after the adapter is created. Adapters consume it via + # ``getattr(self, "gateway_runner", None)`` for cross-platform delivery and + # — critically — for inbound profile routing: ``build_source`` resolves the + # target profile through ``runner._profile_name_for_source(...)``. Declaring + # it on the base (rather than only on adapters that happen to pre-declare + # it) means EVERY platform adapter receives the injection, so profile + # routing is platform-generic instead of Discord-only. + gateway_runner = None # type: ignore[assignment] # set by gateway/run.py + def __init__(self, config: PlatformConfig, platform: Platform): self.config = config self.platform = platform @@ -3179,6 +3220,30 @@ class BasePlatformAdapter(ABC): """ pass + async def _stop_typing_with_metadata(self, chat_id: str, metadata=None) -> None: + """Stop typing while preserving platform-specific routing metadata. + + Most adapters key typing state by chat and retain the historical + ``stop_typing(chat_id)`` signature. Slack AI status is per thread and + workspace, however, so losing metadata can clear a sibling thread or + leave the current one active. Introspect at this shared chokepoint so + existing adapters remain source-compatible. + """ + if metadata: + try: + params = inspect.signature(self.stop_typing).parameters + accepts_metadata = "metadata" in params or any( + param.kind is inspect.Parameter.VAR_KEYWORD + for param in params.values() + ) + except (TypeError, ValueError): + accepts_metadata = False + if accepts_metadata: + stop_typing = getattr(self, "stop_typing") + await stop_typing(chat_id, metadata=metadata) + return + await self.stop_typing(chat_id) + async def send_multiple_images( self, chat_id: str, @@ -3856,7 +3921,7 @@ class BasePlatformAdapter(ABC): # Cancelling _keep_typing alone won't clean that up. if hasattr(self, "stop_typing"): try: - await self.stop_typing(chat_id) + await self._stop_typing_with_metadata(chat_id, metadata) except Exception: pass self._typing_paused.discard(chat_id) @@ -3866,6 +3931,7 @@ class BasePlatformAdapter(ABC): chat_id: str, typing_task: asyncio.Task | None = None, *, + metadata=None, timeout: float = 0.5, stop_attempts: int = 2, ) -> None: @@ -3885,7 +3951,7 @@ class BasePlatformAdapter(ABC): attempts = max(1, stop_attempts) for attempt in range(attempts): try: - await self.stop_typing(chat_id) + await self._stop_typing_with_metadata(chat_id, metadata) except Exception: pass if attempt < attempts - 1: @@ -3905,14 +3971,14 @@ class BasePlatformAdapter(ABC): """Resume typing indicator for a chat after approval resolves.""" self._typing_paused.discard(chat_id) - async def interrupt_session_activity(self, session_key: str, chat_id: str) -> None: + async def interrupt_session_activity(self, session_key: str, chat_id: str, metadata=None) -> None: """Signal the active session loop to stop and clear typing immediately.""" if session_key: interrupt_event = self._active_sessions.get(session_key) if interrupt_event is not None: interrupt_event.set() try: - await self.stop_typing(chat_id) + await self._stop_typing_with_metadata(chat_id, metadata) except Exception: pass @@ -4850,6 +4916,7 @@ class BasePlatformAdapter(ABC): await self._stop_typing_refresh( event.source.chat_id, typing_task, + metadata=_thread_metadata, ) try: @@ -5273,6 +5340,7 @@ class BasePlatformAdapter(ABC): await self._stop_typing_refresh( event.source.chat_id, None, + metadata=_thread_metadata, stop_attempts=1, ) # Final drain/release boundary: force-flush any timer that missed @@ -5441,6 +5509,7 @@ class BasePlatformAdapter(ABC): user_id_alt: Optional[str] = None, chat_id_alt: Optional[str] = None, is_bot: bool = False, + scope_id: Optional[str] = None, guild_id: Optional[str] = None, parent_chat_id: Optional[str] = None, message_id: Optional[str] = None, @@ -5448,10 +5517,47 @@ class BasePlatformAdapter(ABC): auto_thread_created: bool = False, auto_thread_initial_name: Optional[str] = None, ) -> SessionSource: - """Helper to build a SessionSource for this platform.""" + """Helper to build a SessionSource for this platform. + + When ``gateway.profile_routes`` is configured, the routing engine + resolves the matching profile from guild/chat/thread and stamps it on + ``source.profile``. Downstream code (``_resolve_profile_home_for_source`` + in run.py) reads that field to enter ``_profile_runtime_scope`` for + per-profile HERMES_HOME isolation. + """ # Normalize empty topic to None if chat_topic is not None and not chat_topic.strip(): chat_topic = None + + # Resolve profile from configured routes (None when no match / no routes) + profile = None + runner = getattr(self, "gateway_runner", None) + if runner is not None: + try: + profile = runner._profile_name_for_source( + SessionSource( + platform=self.platform, + chat_id=str(chat_id), + chat_name=chat_name, + chat_type=chat_type, + user_id=str(user_id) if user_id else None, + user_name=user_name, + thread_id=str(thread_id) if thread_id else None, + chat_topic=chat_topic.strip() if chat_topic else None, + user_id_alt=user_id_alt, + chat_id_alt=chat_id_alt, + is_bot=is_bot, + guild_id=str(guild_id) if guild_id else None, + parent_chat_id=str(parent_chat_id) if parent_chat_id else None, + message_id=str(message_id) if message_id else None, + ) + ) + except Exception: + logger.warning( + "Profile resolution failed for %s/%s, defaulting to active profile", + self.platform, chat_id, exc_info=True, + ) + return SessionSource( platform=self.platform, chat_id=str(chat_id), @@ -5464,9 +5570,11 @@ class BasePlatformAdapter(ABC): user_id_alt=user_id_alt, chat_id_alt=chat_id_alt, is_bot=is_bot, + scope_id=str(scope_id) if scope_id else None, guild_id=str(guild_id) if guild_id else None, parent_chat_id=str(parent_chat_id) if parent_chat_id else None, message_id=str(message_id) if message_id else None, + profile=profile, role_authorized=role_authorized, auto_thread_created=auto_thread_created, auto_thread_initial_name=auto_thread_initial_name, diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 2639ab52fdd3..5447d99d9960 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -278,8 +278,16 @@ class QQAdapter(BasePlatformAdapter): # Connection lifecycle # ------------------------------------------------------------------ - async def connect(self) -> bool: - """Authenticate, obtain gateway URL, and open the WebSocket.""" + async def connect(self, *, is_reconnect: bool = False) -> bool: + """ + Authenticate, obtain gateway URL, and open the WebSocket. + + Args: + is_reconnect: False on a cold first boot; True when the + reconnect watcher is re-establishing this platform after + an outage. QQBot has no server-side update queue so this + flag is accepted for interface conformance only. + """ if not AIOHTTP_AVAILABLE: message = "QQ startup failed: aiohttp not installed" self._set_fatal_error("qq_missing_dependency", message, retryable=True) @@ -2640,7 +2648,10 @@ class QQAdapter(BasePlatformAdapter): return await self.send_with_keyboard( chat_id, build_approval_text(req), - build_approval_keyboard(req.session_key), + build_approval_keyboard( + req.session_key, + allow_permanent=getattr(req, "allow_permanent", True), + ), reply_to=reply_to, ) @@ -2660,6 +2671,8 @@ class QQAdapter(BasePlatformAdapter): session_key: str, description: str = "dangerous command", metadata: Optional[Dict[str, Any]] = None, + allow_permanent: bool = True, + smart_denied: bool = False, ) -> SendResult: """Send a button-based exec-approval prompt for a dangerous command. @@ -2669,6 +2682,8 @@ class QQAdapter(BasePlatformAdapter): adapter's interaction callback (:meth:`_default_interaction_dispatch`). """ del metadata # QQ doesn't have thread_id / DM targeting overrides. + if smart_denied: + description += " Owner override applies to this one operation only." # Use the reply-to message for passive-message context when we have one. # QQ requires a msg_id on outbound messages to a user we've never @@ -2681,6 +2696,7 @@ class QQAdapter(BasePlatformAdapter): description=description, command_preview=command, timeout_sec=self._APPROVAL_TIMEOUT_SECONDS, + allow_permanent=allow_permanent and not smart_denied, ) return await self.send_approval_request( chat_id, req, reply_to=msg_id, diff --git a/gateway/platforms/qqbot/keyboards.py b/gateway/platforms/qqbot/keyboards.py index 19fd36e370d5..9c1afbbfa337 100644 --- a/gateway/platforms/qqbot/keyboards.py +++ b/gateway/platforms/qqbot/keyboards.py @@ -201,8 +201,8 @@ def _make_callback_button( ) -def build_approval_keyboard(session_key: str) -> InlineKeyboard: - """Build the 3-button approval keyboard. +def build_approval_keyboard(session_key: str, *, allow_permanent: bool = True) -> InlineKeyboard: + """Build the approval keyboard, hiding persistent scope when unavailable. Layout: ``[✅ 允许一次] [⭐ 始终允许] [❌ 拒绝]`` — all three share ``group_id='approval'`` so clicking one greys out the rest. @@ -210,38 +210,25 @@ def build_approval_keyboard(session_key: str) -> InlineKeyboard: :param session_key: Embedded into ``button_data`` so the decision routes back to the right pending approval. """ - return InlineKeyboard( - content=KeyboardContent( - rows=[ - KeyboardRow(buttons=[ - _make_callback_button( - btn_id="allow", - label="✅ 允许一次", - visited_label="已允许", - data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-once", - style=1, - group_id="approval", - ), - _make_callback_button( - btn_id="always", - label="⭐ 始终允许", - visited_label="已始终允许", - data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-always", - style=1, - group_id="approval", - ), - _make_callback_button( - btn_id="deny", - label="❌ 拒绝", - visited_label="已拒绝", - data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:deny", - style=0, - group_id="approval", - ), - ]), - ] + buttons = [ + _make_callback_button( + btn_id="allow", label="✅ 允许一次", visited_label="已允许", + data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-once", + style=1, group_id="approval", ) - ) + ] + if allow_permanent: + buttons.append(_make_callback_button( + btn_id="always", label="⭐ 始终允许", visited_label="已始终允许", + data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-always", + style=1, group_id="approval", + )) + buttons.append(_make_callback_button( + btn_id="deny", label="❌ 拒绝", visited_label="已拒绝", + data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:deny", + style=0, group_id="approval", + )) + return InlineKeyboard(content=KeyboardContent(rows=[KeyboardRow(buttons=buttons)])) def build_update_prompt_keyboard() -> InlineKeyboard: @@ -295,6 +282,7 @@ class ApprovalRequest: tool_name: str = "" severity: str = "" timeout_sec: int = 120 + allow_permanent: bool = True def build_approval_text(req: ApprovalRequest) -> str: diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index d78eb4aad6dc..980be11709ec 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -1515,6 +1515,7 @@ class WeixinAdapter(BasePlatformAdapter): event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + profile=event.source.profile, ) def _enqueue_text_event(self, event: MessageEvent) -> None: diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index c97122ede1d2..b494fc15e5e2 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -387,6 +387,20 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return True return super()._open_dm_opted_in() + def _is_interactive_sender_authorized(self, sender_id: str) -> bool: + """Authorize inbound button/list taps before running resolvers. + + Interactive replies bypass the normal ``_build_message_event_from_cloud`` + path (which calls ``_should_process_message``), so approval / + slash-confirm / clarify taps must re-check DM policy here. Uses the + strict ``_is_dm_allowed`` gate (not intake/pairing) so a stale prompt + cannot be answered after the sender is removed from the allowlist. + """ + principal = str(sender_id or "").strip() + if not principal: + return False + return self._is_dm_allowed(principal) + # ------------------------------------------------------------------ lifecycle async def connect(self, *, is_reconnect: bool = False) -> bool: if not check_whatsapp_cloud_requirements(): @@ -805,6 +819,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): session_key: str, description: str = "dangerous command", metadata: Optional[Dict[str, Any]] = None, + allow_permanent: bool = True, + smart_denied: bool = False, ) -> SendResult: """Render a dangerous-command approval prompt with native buttons. @@ -816,6 +832,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if self._http_client is None: return SendResult(success=False, error="Not connected") + del allow_permanent # This adapter already offers one-shot Approve / Deny only. # WhatsApp body caps at 1024 chars; reserve room for the # framing prose around the command. cmd = command or "" @@ -824,6 +841,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): f"⚠️ *Command Approval Required*\n\n" f"```\n{cmd_preview}\n```\n\n" f"Reason: {description}" + + ("\n\nSmart DENY: owner override applies to this one operation only." if smart_denied else "") ) approval_id = uuid.uuid4().hex[:12] @@ -1635,6 +1653,18 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if not button_id: return False + sender_id = str(raw_message.get("from") or "").strip() + if not self._is_interactive_sender_authorized(sender_id): + logger.warning( + "[whatsapp_cloud] Rejected unauthorized interactive tap " + "from %s (button_id=%r)", + sender_id or "<unknown>", + button_id, + ) + # Claim the webhook entry so the tap is not re-dispatched as + # plain text (which could re-enter the agent loop). + return True + # Clarify: cl:<clarify_id>:<idx|other> if button_id.startswith("cl:"): parts = button_id.split(":", 2) diff --git a/gateway/profile_routing.py b/gateway/profile_routing.py new file mode 100644 index 000000000000..c0ec0acc2bf9 --- /dev/null +++ b/gateway/profile_routing.py @@ -0,0 +1,166 @@ +"""Profile-based routing for the gateway with hierarchical matching. + +Allows a single Hermes instance to route specific Discord guilds/channels/threads +to different profiles — each with their own model, tools, memory, and persona. + +Matching priority (most specific first): + 1. platform + chat_id + thread_id (exact thread) — specificity 14 + 2. platform + chat_id (channel route) — specificity 6 + 3. platform + guild_id (guild/server route) — specificity 2 + 4. No match → default profile + +Parent-chain matching: +For Discord threads and forum posts, ``parent_chat_id`` carries the +direct parent (the channel for a thread, the forum channel for a post). +Routes keyed on a channel match both direct messages and messages in +any thread/post whose parent is that channel. + +Configuration (config.yaml): + + gateway: + profile_routes: + - name: server-default + platform: discord + guild_id: "YOUR_GUILD_ID" + profile: server-profile + + - name: special-channel + platform: discord + guild_id: "YOUR_GUILD_ID" + chat_id: "YOUR_CHANNEL_ID" + profile: channel-profile + + - name: thread-route + platform: discord + chat_id: "YOUR_CHANNEL_ID" + thread_id: "YOUR_THREAD_ID" + profile: thread-profile +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +import logging + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ProfileRoute: + """A single routing rule that maps a platform scope to a profile.""" + + name: str + platform: str + profile: str + guild_id: Optional[str] = None + chat_id: Optional[str] = None + thread_id: Optional[str] = None + enabled: bool = True + + @property + def specificity(self) -> int: + """Higher value = more specific match.""" + s = 0 + if self.guild_id: + s += 2 + if self.chat_id: + s += 4 + if self.thread_id: + s += 8 + return s + + def matches( + self, + platform: str, + guild_id: Optional[str] = None, + chat_id: Optional[str] = None, + thread_id: Optional[str] = None, + parent_chat_id: Optional[str] = None, + ) -> bool: + """Return True if this route matches the given source fields. + + All configured discriminators are matched conjunctively (AND): every + discriminator that the route declares must hold. ``chat_id`` supports + hierarchical matching for Discord forums/threads: + - Direct channel match: chat_id == route.chat_id + - Thread in channel: parent_chat_id == route.chat_id + A route declaring both ``guild_id`` and ``chat_id`` requires both to + match (a chat match alone does not satisfy a guild constraint). + """ + if not self.enabled: + return False + if self.platform != platform: + return False + if self.thread_id and self.thread_id != thread_id: + return False + if self.chat_id and self.chat_id != chat_id and self.chat_id != parent_chat_id: + return False + if self.guild_id and self.guild_id != guild_id: + return False + return True + + +def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRoute]: + """Parse profile_routes from config.yaml into ProfileRoute objects. + + Returns routes sorted by specificity (most specific first). + """ + if not raw: + return [] + routes: List[ProfileRoute] = [] + for entry in raw: + if not isinstance(entry, dict): + continue + name = entry.get("name", "") + platform = entry.get("platform", "") + profile = entry.get("profile", "") + if not platform or not profile: + logger.warning( + "Skipping profile route %s: missing platform or profile", + name, + ) + continue + # Validate profile name to prevent path traversal. Lazy import avoids a + # circular dependency at module load time. + try: + from hermes_cli.profiles import ( + normalize_profile_name, + validate_profile_name, + ) + profile = normalize_profile_name(profile) + validate_profile_name(profile) + except (ValueError, ImportError): + logger.warning("Skipping profile route %s: invalid profile name %r", name, profile) + continue + routes.append( + ProfileRoute( + name=name, + platform=platform, + profile=profile, + guild_id=entry.get("guild_id"), + chat_id=entry.get("chat_id"), + thread_id=entry.get("thread_id"), + enabled=entry.get("enabled", True), + ) + ) + # Sort: most specific first so the first match wins. + routes.sort(key=lambda r: r.specificity, reverse=True) + logger.debug("Loaded %d profile routes (most-specific-first)", len(routes)) + return routes + + +def match_profile_route( + routes: List[ProfileRoute], + platform: str, + guild_id: Optional[str] = None, + chat_id: Optional[str] = None, + thread_id: Optional[str] = None, + parent_chat_id: Optional[str] = None, +) -> Optional[ProfileRoute]: + """Return the best-matching route, or None for no match.""" + for route in routes: + if route.matches(platform, guild_id=guild_id, chat_id=chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id): + return route + return None diff --git a/gateway/readiness.py b/gateway/readiness.py new file mode 100644 index 000000000000..379e074bb40d --- /dev/null +++ b/gateway/readiness.py @@ -0,0 +1,117 @@ +"""Bounded, non-destructive readiness probes for authenticated health surfaces.""" + +from __future__ import annotations + +import shutil +import sqlite3 +from pathlib import Path +from typing import Any + +import yaml + +from hermes_constants import get_hermes_home + + +_DISK_DEGRADED_PERCENT = 90.0 + + +def _check(status: str, detail: str | None = None, **extra: Any) -> dict[str, Any]: + result: dict[str, Any] = {"status": status} + if detail: + result["detail"] = detail + result.update(extra) + return result + + +def _probe_state_db(home: Path) -> dict[str, Any]: + path = home / "state.db" + if not path.exists(): + return _check("ok", "not initialized") + try: + # A readiness probe must never compete with normal state writers. A + # read-only schema query still catches unreadable/corrupt databases + # without taking a write reservation on every health poll. + uri = f"file:{path.as_posix()}?mode=ro" + with sqlite3.connect(uri, uri=True, timeout=1.0) as conn: + conn.execute("PRAGMA query_only = ON") + conn.execute("SELECT name FROM sqlite_master LIMIT 1").fetchone() + return _check("ok") + except Exception as exc: + return _check("degraded", type(exc).__name__) + + +def _probe_config(home: Path) -> dict[str, Any]: + path = home / "config.yaml" + if not path.exists(): + return _check("ok", "using defaults") + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if raw is not None and not isinstance(raw, dict): + return _check("degraded", "top level is not a mapping") + return _check("ok") + except Exception as exc: + return _check("degraded", f"invalid config ({type(exc).__name__})") + + +def _probe_disk(home: Path) -> dict[str, Any]: + try: + usage = shutil.disk_usage(home) + used_pct = round((usage.used / usage.total) * 100, 1) if usage.total else 0.0 + status = "degraded" if used_pct >= _DISK_DEGRADED_PERCENT else "ok" + return _check(status, used_percent=used_pct, free_bytes=usage.free) + except Exception as exc: + return _check("degraded", type(exc).__name__) + + +def _probe_gateway(runtime_status: dict[str, Any]) -> dict[str, Any]: + state = str(runtime_status.get("gateway_state") or "unknown") + platforms = runtime_status.get("platforms") + connected = 0 + configured = 0 + if isinstance(platforms, dict): + configured = len(platforms) + connected = sum( + 1 + for value in platforms.values() + if isinstance(value, dict) + and str(value.get("state") or value.get("status") or "").lower() + in {"connected", "running", "ok"} + ) + status = "ok" if state in {"running", "draining"} else "degraded" + return _check(status, state=state, connected_platforms=connected, platforms=configured) + + +def collect_runtime_readiness( + *, + configured_model: str, + runtime_status: dict[str, Any] | None, + active_api_runs: int = 0, + process_completion_queue_depth: int = 0, + active_delegations: int = 0, +) -> dict[str, Any]: + """Return bounded readiness diagnostics without mutating runtime state. + + The detailed health endpoint is authenticated. Even there, probes expose + status and counts only: never config values, credentials, paths, commands, + queue payloads, or exception messages. + """ + home = get_hermes_home() + runtime = runtime_status if isinstance(runtime_status, dict) else {} + checks = { + "state_db": _probe_state_db(home), + "config": _probe_config(home), + "model": _check("ok" if str(configured_model or "").strip() else "degraded"), + "disk": _probe_disk(home), + "gateway": _probe_gateway(runtime), + "background_queues": _check( + "ok", + active_api_runs=max(0, int(active_api_runs)), + process_completions=max(0, int(process_completion_queue_depth)), + active_delegations=max(0, int(active_delegations)), + ), + } + overall = "ok" if all(item.get("status") == "ok" for item in checks.values()) else "degraded" + return {"status": overall, "checks": checks} + + +__all__ = ["collect_runtime_readiness"] diff --git a/gateway/relay/__init__.py b/gateway/relay/__init__.py index 0c64aaedeb5a..87326c4e4a01 100644 --- a/gateway/relay/__init__.py +++ b/gateway/relay/__init__.py @@ -36,7 +36,8 @@ def relay_url() -> Optional[str]: from gateway.run import _load_gateway_config # late import to avoid cycle cfg = _load_gateway_config() - url = (cfg.get("gateway") or {}).get("relay_url", "").strip() + url = (cfg.get("gateway") or {}).get("relay_url") + url = (url or "").strip() if url: return url.rstrip("/") except Exception: # noqa: BLE001 - config absence/parse must never crash registration diff --git a/gateway/relay/descriptor.py b/gateway/relay/descriptor.py index 2a8d0fe78f34..9ddbee011869 100644 --- a/gateway/relay/descriptor.py +++ b/gateway/relay/descriptor.py @@ -58,6 +58,12 @@ class CapabilityDescriptor: emoji: str = "\U0001f50c" # 🔌 default (matches PlatformEntry default) platform_hint: str = "" pii_safe: bool = False + # Whether the connector can supply surrounding channel/group CONTEXT for an + # addressed turn (Model A pull / Model B buffer, per platform). Optional + + # defaults False so an older connector that never sends it is treated as + # "no context" — additive within contract_version 1. from_json filters + # unknown keys, so a connector sending this to an older gateway is safe too. + supports_context: bool = False def to_json(self) -> str: """Serialize to a compact, stable JSON string for the handshake frame.""" diff --git a/gateway/relay/ws_transport.py b/gateway/relay/ws_transport.py index f93c3c2cd927..33aaaa9dbcd9 100644 --- a/gateway/relay/ws_transport.py +++ b/gateway/relay/ws_transport.py @@ -34,7 +34,7 @@ import json import logging import uuid from dataclasses import dataclass -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from gateway.platforms.base import MessageEvent, MessageType from gateway.session import SessionSource @@ -91,6 +91,44 @@ def _ws_dial_url(url: str) -> str: return raw +def _render_relay_context(context: Any) -> Optional[str]: + """Render the connector's read-only surrounding-context array into the string + ``MessageEvent.channel_context`` field. + + The connector attaches ``context`` as a list of normalized message objects + (oldest→newest, same channel) for an addressed turn on a context-capable + platform (design relay-channel-context). We flatten each to a + ``<author>: <text>`` line so it rides the SAME read-only injection path that + history-backfill already uses (run.py prepends ``channel_context`` ahead of + the trigger message). This is REFERENCE context only — it never triggers the + agent; the trigger decision was already made connector-side on the addressed + event alone. + + Returns None when there is no usable context (absent/empty list, or a + connector that doesn't send the field), so ``channel_context`` stays unset + and behaviour is byte-identical to today. Never raises — a malformed context + payload must not break inbound delivery of the (already-admitted) turn. + """ + if not context or not isinstance(context, list): + return None + lines: List[str] = [] + for item in context: + if not isinstance(item, dict): + continue + text = item.get("text") + if not text: + continue + src = item.get("source") or {} + author = "" + if isinstance(src, dict): + author = src.get("user_name") or src.get("user_id") or "" + lines.append(f"{author}: {text}" if author else str(text)) + if not lines: + return None + body = "\n".join(lines) + return f"[Recent channel messages]\n{body}" + + def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent: """Rebuild a MessageEvent from the connector's normalized inbound payload. @@ -150,6 +188,15 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent: message_id=raw.get("message_id"), reply_to_message_id=raw.get("reply_to_message_id"), media_urls=raw.get("media_urls") or [], + # Surrounding channel/group CONTEXT the connector attached for this + # addressed turn (design relay-channel-context): a read-only, oldest→ + # newest list of nearby non-addressed messages (Model A pull / Model B + # buffer). Rendered into the existing ``channel_context`` field — the + # same read-only injection path history-backfill already uses + # (run.py prepends it ahead of the trigger message). Absent / empty on a + # connector that doesn't send it, a dm, or a no-context platform, so + # this is purely additive and byte-identical to today when unset. + channel_context=_render_relay_context(raw.get("context")), ) diff --git a/gateway/run.py b/gateway/run.py index e71fe26ba8ea..f05d89540b5e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -353,6 +353,34 @@ def _redact_approval_command(cmd: "str | None") -> str: return redact_sensitive_text(str(cmd or ""), force=True) +def _format_exec_approval_fallback( + command: str, + description: str, + command_prefix: str, + *, + allow_permanent: bool = True, + smart_denied: bool = False, +) -> str: + """Render the text fallback from approval capabilities, not platform names.""" + cmd_preview = command[:200] + "..." if len(command) > 200 else command + heading = "⚠️ **Dangerous command requires approval:**" + if smart_denied: + heading = "⚠️ **Smart DENY — owner override for one operation:**" + + choices = [f"Reply `{command_prefix}approve` to execute this one operation"] + if not smart_denied: + choices.append( + f"`{command_prefix}approve session` to approve this pattern for the session" + ) + if allow_permanent: + choices.append(f"`{command_prefix}approve always` to approve permanently") + choices.append(f"`{command_prefix}deny` to cancel") + return ( + f"{heading}\n```\n{cmd_preview}\n```\nReason: {description}\n\n" + + ", ".join(choices[:-1]) + f", or {choices[-1]}." + ) + + def _gateway_provider_error_reply(text: str) -> str: """Map raw provider/API errors to a short user-safe Telegram reply.""" if _GATEWAY_AUTH_ERROR_RE.search(text): @@ -1744,6 +1772,7 @@ from gateway.config import ( load_gateway_config, ) from gateway.session import ( + AsyncSessionStore, SessionStore, SessionSource, SessionContext, @@ -1751,6 +1780,7 @@ from gateway.session import ( build_session_context_prompt, build_session_key, is_shared_multi_user_session, + neutralize_untrusted_inline_text, ) from gateway.delivery import DeliveryRouter, looks_like_telegram_private_chat_id from gateway.authz_mixin import GatewayAuthorizationMixin @@ -2638,6 +2668,8 @@ def _normalize_empty_agent_response( ) return response if api_calls > 0: + if _is_gateway_hidden_reasoning_incomplete_turn(agent_result): + return "" if agent_result.get("partial"): err = agent_result.get("error", "processing incomplete") return f"⚠️ Processing stopped: {str(err)[:200]}. Try again." @@ -2665,6 +2697,30 @@ def _normalize_empty_agent_response( return response +def _is_gateway_hidden_reasoning_incomplete_turn(agent_result: dict) -> bool: + """Detect retry-exhausted turns with hidden reasoning but no visible answer. + + The conversation loop returns the retry-exhaustion sentinel as BOTH + ``final_response`` and ``error`` ("Codex response remained incomplete + after 3 continuation attempts"), so ``final_response`` being non-empty + does not mean the model produced a visible answer. Treat the turn as + hidden when the error sentinel is present and ``final_response`` is + either empty or merely echoes that sentinel — any genuinely different + final text means the model DID answer and must be delivered. + """ + if not isinstance(agent_result, dict): + return False + if agent_result.get("failed") or agent_result.get("interrupted"): + return False + if not agent_result.get("partial"): + return False + error_text = str(agent_result.get("error", "") or "").strip() + if "remained incomplete after" not in error_text.lower(): + return False + final_response = str(agent_result.get("final_response") or "").strip() + return not final_response or final_response == error_text + + def _should_clear_resume_pending_after_turn(agent_result: dict) -> bool: """Return True only when a gateway turn really completed successfully. @@ -2853,6 +2909,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew key, max_active_age=_bg_max_age_seconds, ), ) + # One enforced loop-side boundary for the synchronous SessionStore. + # Sync helpers keep using ``session_store`` directly; async gateway + # handlers call this facade and await every operation. + self._async_session_store = AsyncSessionStore(self.session_store) self.delivery_router = DeliveryRouter(self.config) self._running = False self._gateway_loop: Optional[asyncio.AbstractEventLoop] = None @@ -2946,6 +3006,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # cannot grow unbounded over a long-running gateway lifetime. self._session_sources: "OrderedDict[str, SessionSource]" = OrderedDict() self._session_sources_max = 512 + # Completion delivery is intentionally lifecycle-scoped. This closes + # duplicate queue/watcher races inside one gateway without pretending + # the adapter call and a persistence write can be exactly-once across + # a process crash. Any durable async-delegation replay state remains + # owned by tools.async_delegation, not a parallel gateway ledger. + self._completion_delivery_lock = threading.Lock() + self._completion_deliveries_inflight: set[tuple[str, str, object]] = set() + self._completion_deliveries_delivered: "OrderedDict[tuple[str, str, object], None]" = OrderedDict() + self._completion_delivery_retention = 2048 # Cache AIAgent instances per session to preserve prompt caching. # Without this, a new AIAgent is created per message, rebuilding the @@ -4080,6 +4149,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew def _running_agent_count(self) -> int: return len(self._running_agents) + def _active_work_count(self) -> int: + """All agent work the gateway must expose and drain as one total.""" + return ( + self._running_agent_count() + + self._active_cron_job_count() + + self._active_api_run_count() + ) + def _active_cron_job_count(self) -> int: """Count of cron jobs currently executing, from the cron scheduler's own in-flight tracking (``cron.scheduler._running_job_ids``). @@ -4100,6 +4177,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: return 0 + def _active_api_run_count(self) -> int: + """Count API-server work that is outside ``_running_agents``. + + The primary API server owns the sole HTTP listener. Secondary multiplex + profiles cannot create an ``api_server`` adapter because it binds a port, + so only the primary registry is a supported source of this work. + """ + try: + adapter = getattr(self, "adapters", {}).get(Platform.API_SERVER) + helper = getattr(adapter, "active_agent_work_count", None) + return max(0, int(helper())) if callable(helper) else 0 + except Exception: + return 0 + # ── scale-to-zero idle detection / dormant-quiesce (Phase 0) ────────────── # The gateway-side BEHAVIOUR that consumes the relay scale-to-zero primitives # (gateway-gateway Phase 5). Pure logic lives in gateway/scale_to_zero.py; the @@ -4487,7 +4578,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew gateway_state=gateway_state, exit_reason=exit_reason, restart_requested=self._restart_requested, - active_agents=self._running_agent_count(), + active_agents=self._active_work_count(), ) except Exception: pass @@ -4510,7 +4601,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew """ try: from gateway.status import write_runtime_status - write_runtime_status(active_agents=self._running_agent_count()) + write_runtime_status(active_agents=self._active_work_count()) except Exception: pass @@ -4537,7 +4628,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.info( "External drain ENGAGED (.drain_request.json present) — refusing " "new turns; %d in-flight turn(s) will finish. Process stays up.", - self._running_agent_count(), + self._active_work_count(), ) # Flip the persisted lifecycle state so /api/status.gateway_busy / # gateway_drainable track the drain. Preserve active_agents (the @@ -4588,6 +4679,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew try: if drain_requested(): self._enter_external_drain() + # API and cron work live outside messaging's + # _running_agents map. Refresh the aggregate while an + # external caller polls this reversible drain state. + self._persist_active_agents() else: self._exit_external_drain() except asyncio.CancelledError: @@ -4783,23 +4878,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew return getattr(self, "_ephemeral_system_prompt", None) or "" @staticmethod - def _load_reasoning_config() -> dict | None: - """Load reasoning effort from config.yaml. + def _load_reasoning_config(model: str = "") -> dict | None: + """Load reasoning effort from config.yaml, respecting per-model overrides. - Reads agent.reasoning_effort from config.yaml. Valid: "none", - "minimal", "low", "medium", "high", "xhigh". Returns None to use - default (medium). + Thin wrapper over the shared chokepoint + :func:`hermes_constants.resolve_reasoning_config` (per-model override > + global ``agent.reasoning_effort``; YAML boolean False = disabled). + Closes #21256. + + Args: + model: The effective model for the calling session. When empty, + the config's ``model.default`` is used. """ - from hermes_constants import parse_reasoning_effort + from hermes_constants import resolve_reasoning_config cfg = _load_gateway_runtime_config() - # Keep the raw value — coercing with ``or ""`` turns a YAML boolean - # False (``reasoning_effort: false``/``off``/``no``) into "", silently - # re-enabling thinking for users who explicitly disabled it. - effort = cfg_get(cfg, "agent", "reasoning_effort", default="") - result = parse_reasoning_effort(effort) - if effort and str(effort).strip() and result is None: - logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort) - return result + return resolve_reasoning_config(cfg, model) @staticmethod def _parse_reasoning_command_args(raw_args: str) -> tuple[str, bool]: @@ -4832,8 +4925,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew *, source: Optional[SessionSource] = None, session_key: Optional[str] = None, + model: str = "", ) -> dict | None: - """Resolve reasoning effort for a session, honoring session overrides.""" + """Resolve reasoning effort for a session, honoring session overrides. + + Priority: session-scoped ``/reasoning --session`` override > + per-model override (``agent.reasoning_overrides``) > global + ``agent.reasoning_effort``. ``model`` should be the session's + *effective* model (session ``/model`` override included) so + per-model overrides track what the session actually runs — when + empty, the config's ``model.default`` is used. + """ resolved_session_key = session_key if not resolved_session_key and source is not None: try: @@ -4844,7 +4946,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew overrides = getattr(self, "_session_reasoning_overrides", {}) or {} if resolved_session_key and resolved_session_key in overrides: return overrides[resolved_session_key] - return self._load_reasoning_config() + return self._load_reasoning_config(model) def _set_session_reasoning_override( self, @@ -5008,6 +5110,74 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew pass return None + def _refresh_fallback_model(self) -> list | None: + """Re-read fallback_providers from disk for the next agent create/reuse. + + Cron already does this per job via ``get_fallback_chain``; the gateway + previously froze ``self._fallback_model`` at process start, so a chain + configured (or changed) after ``hermes gateway`` was running never + reached messaging sessions even though the same process's cron jobs + fell back correctly. Fixes #60955. + + A TRANSIENT read/parse failure (user mid-edit of config.yaml with a + non-atomic write) keeps the last known-good chain instead of wiping a + cached agent's working fallback for that turn. Only a successful read + that genuinely lacks the key clears the chain. + """ + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if not cfg_path.exists(): + self._fallback_model = None + return self._fallback_model + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + except Exception: + # Transient failure — keep last known-good chain. + logger.debug( + "fallback_providers refresh: config.yaml read failed; " + "keeping last known-good chain", exc_info=True, + ) + return self._fallback_model + self._fallback_model = get_fallback_chain(cfg) or None + return self._fallback_model + + @staticmethod + def _apply_fallback_chain_to_agent(agent: Any, chain: list | None) -> None: + """Keep a cached agent's fallback chain aligned with current config. + + Skips rewrite while a cooldown is holding the agent on an already- + activated fallback provider — ``restore_primary_runtime`` owns that + turn-scoped lifecycle. When primary is active (or cooldown expired), + replace the chain so mid-uptime ``fallback_providers`` edits take + effect without requiring a gateway restart (#60955). + """ + if agent is None: + return + new_chain = list(chain or []) + rate_limited_until = getattr(agent, "_rate_limited_until", 0) or 0 + if ( + getattr(agent, "_fallback_activated", False) + and rate_limited_until > time.monotonic() + ): + return + old_chain = list(getattr(agent, "_fallback_chain", []) or []) + agent._fallback_chain = new_chain + agent._fallback_model = new_chain[0] if new_chain else None + if not getattr(agent, "_fallback_activated", False): + agent._fallback_index = 0 + # A config edit signals the user changed something — drop the + # session-scoped unavailability memo so re-configured entries + # (e.g. credentials added mid-uptime for a previously-failing + # provider) get retried instead of staying suppressed for the + # cached agent's lifetime. Only on actual content change, so + # the per-message no-op refresh keeps the memo's rate-limiting + # benefit (#60955). + if new_chain != old_chain: + unavailable = getattr(agent, "_unavailable_fallback_keys", None) + if unavailable: + unavailable.clear() + def _snapshot_running_agents(self) -> Dict[str, Any]: return { session_key: agent @@ -5106,7 +5276,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: return False - def _session_has_compression_in_flight(self, session_key: str) -> bool: + async def _session_has_compression_in_flight(self, session_key: str) -> bool: """Return True when a compression lock is held for this session's id. Context compression is interrupt-protected (#23975) but gateway @@ -5114,27 +5284,60 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew the pre-rotation parent while compression is mid-flight, producing orphaned compression siblings (#56391). Callers demote interrupt to queue when this returns True. + + Both blocking sources — the ``session_store`` lock + JSON load, and the + SQLite ``get_compression_lock_holder`` SELECT — are offloaded to a + worker thread so a large state.db never freezes the event loop (#5). """ session_store = getattr(self, "session_store", None) if not session_key or session_store is None: return False try: - with session_store._lock: # noqa: SLF001 — snapshot entry under lock - session_store._ensure_loaded_locked() # noqa: SLF001 - entry = session_store._entries.get(session_key) # noqa: SLF001 - session_id = getattr(entry, "session_id", None) if entry is not None else None - if not session_id: - return False + session_id = await asyncio.to_thread( + self._lookup_session_id_under_store_lock, session_store, session_key + ) + except (AttributeError, TypeError): + return False except Exception: + logger.warning( + "Compression in-flight check failed while reading session %s; " + "treating compression as active to avoid interrupting a possible " + "parent-session rotation", + session_key, + exc_info=True, + ) + return True + if not session_id: return False session_db = getattr(self, "_session_db", None) if session_db is None: return False - db = getattr(session_db, "_db", session_db) + raw_db = getattr(session_db, "_db", session_db) try: - return bool(db.get_compression_lock_holder(str(session_id))) - except Exception: + holder = await asyncio.to_thread( + raw_db.get_compression_lock_holder, str(session_id) + ) + return bool(holder) + except (AttributeError, TypeError): return False + except Exception: + logger.warning( + "Compression in-flight check failed while reading lock holder " + "for session %s; treating compression as active to avoid " + "interrupting a possible parent-session rotation", + session_id, + exc_info=True, + ) + return True + + @staticmethod + def _lookup_session_id_under_store_lock(session_store, session_key: str): + """Sync helper run in the thread pool: read session_id under the store lock.""" + # noqa: SLF001 — intentional private access; runs off the event loop. + with session_store._lock: # noqa: SLF001 + session_store._ensure_loaded_locked() # noqa: SLF001 + entry = session_store._entries.get(session_key) # noqa: SLF001 + return getattr(entry, "session_id", None) if entry is not None else None # Hard cap on per-session pending follow-ups for busy_input_mode=queue # (and the draining/steer-fallback/subagent-demotion paths that share @@ -5358,7 +5561,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew effective_mode = "queue" demoted_for_compression = ( effective_mode == "interrupt" - and self._session_has_compression_in_flight(session_key) + and await self._session_has_compression_in_flight(session_key) ) if demoted_for_compression: logger.info( @@ -5571,22 +5774,26 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew snapshot = self._snapshot_running_agents() last_active_count = self._running_agent_count() last_cron_count = self._active_cron_job_count() + last_api_count = self._active_api_run_count() last_status_at = 0.0 def _maybe_update_status(force: bool = False) -> None: - nonlocal last_active_count, last_cron_count, last_status_at + nonlocal last_active_count, last_cron_count, last_api_count, last_status_at now = asyncio.get_running_loop().time() active_count = self._running_agent_count() cron_count = self._active_cron_job_count() + api_count = self._active_api_run_count() if ( force or active_count != last_active_count or cron_count != last_cron_count + or api_count != last_api_count or (now - last_status_at) >= 1.0 ): self._update_runtime_status("draining") last_active_count = active_count last_cron_count = cron_count + last_api_count = api_count last_status_at = now # Cron jobs run on the scheduler's own thread pool, outside @@ -5594,7 +5801,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # same wait/timeout this method already applies to chat sessions, # or a cron job's tool work gets killed with zero warning the # instant it's the only active thing running (#60432). - if not self._running_agents and last_cron_count == 0: + # API-server / desk sessions have the same structural gap (#63529). + if not self._running_agents and last_cron_count == 0 and last_api_count == 0: _maybe_update_status(force=True) return snapshot, False @@ -5604,12 +5812,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew deadline = asyncio.get_running_loop().time() + timeout while ( - (self._running_agents or self._active_cron_job_count()) + ( + self._running_agents + or self._active_cron_job_count() + or self._active_api_run_count() + ) and asyncio.get_running_loop().time() < deadline ): _maybe_update_status() await asyncio.sleep(0.1) - timed_out = bool(self._running_agents) or bool(self._active_cron_job_count()) + timed_out = ( + bool(self._running_agents) + or bool(self._active_cron_job_count()) + or bool(self._active_api_run_count()) + ) _maybe_update_status(force=True) return snapshot, timed_out @@ -5647,7 +5863,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew source = None try: if getattr(self, "session_store", None) is not None: - self.session_store._ensure_loaded() + await self.async_session_store._ensure_loaded() entry = self.session_store._entries.get(session_key) source = getattr(entry, "origin", None) if entry else None except Exception as e: @@ -6917,7 +7133,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew pass else: try: - suspended = self.session_store.suspend_recently_active() + suspended = await self.async_session_store.suspend_recently_active() if suspended: logger.info("Marked %d in-flight session(s) as resumable from previous run", suspended) except Exception as e: @@ -7478,13 +7694,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Make sure there's an entry in the session_store for this key. If # the home channel has never been used, get_or_create_session # creates one; switch_session then re-points it. - self.session_store.get_or_create_session(dest_source) + await self.async_session_store.get_or_create_session(dest_source) # Re-bind the destination key to the CLI session_id. switch_session # ends the prior session in SQLite and reopens the CLI session under # the new key. The CLI's transcript becomes the active one for the # gateway from this moment on. - switched = self.session_store.switch_session(session_key, cli_session_id) + switched = await self.async_session_store.switch_session(session_key, cli_session_id) if switched is None: raise RuntimeError( f"could not switch session key {session_key} → {cli_session_id}" @@ -7561,13 +7777,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _MAX_FINALIZE_RETRIES = 3 while self._running: try: - self.session_store._ensure_loaded() + await self.async_session_store._ensure_loaded() # Collect expired sessions first, then log a single summary. _expired_entries = [] for key, entry in list(self.session_store._entries.items()): if entry.expiry_finalized: continue - if not self.session_store._is_session_expired(entry): + if not await self.async_session_store._is_session_expired(entry): continue _expired_entries.append((key, entry)) @@ -7651,7 +7867,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # state.db (single write-path, #9006) — also drops # the persisted /model override, since finalization # is a conversation boundary. - self.session_store.set_expiry_finalized(entry) + await self.async_session_store.set_expiry_finalized(entry) logger.debug( "Session expiry finalized for %s", entry.session_id, @@ -7666,7 +7882,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "Marking as finalized to prevent infinite retry loop.", failures, entry.session_id, e, ) - self.session_store.set_expiry_finalized( + await self.async_session_store.set_expiry_finalized( entry, clear_model_override=False ) _finalize_failures.pop(entry.session_id, None) @@ -7719,7 +7935,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew getattr(self.config, "session_store_max_age_days", 0) or 0 ) if _max_age > 0: - _pruned = self.session_store.prune_old_entries(_max_age) + _pruned = await self.async_session_store.prune_old_entries(_max_age) if _pruned: logger.info( "SessionStore prune: dropped %d stale entries", @@ -8053,7 +8269,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if _agent is _AGENT_PENDING_SENTINEL: continue try: - self.session_store.mark_resume_pending( + await self.async_session_store.mark_resume_pending( _sk, "restart_timeout" if self._restart_requested else "shutdown_timeout", ) @@ -8062,12 +8278,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.debug("pre-drain mark_resume_pending failed for %s: %s", _sk, _e) _cron_at_start = self._active_cron_job_count() + _api_at_start = self._active_api_run_count() _drain_started_at = time.monotonic() active_agents, timed_out = await self._drain_active_agents(timeout) logger.info( "Shutdown phase: drain done at +%.2fs (drain took %.2fs, " "timed_out=%s, active_at_start=%d, active_now=%d, " - "cron_at_start=%d, cron_now=%d)", + "cron_at_start=%d, cron_now=%d, " + "api_at_start=%d, api_now=%d)", _phase_elapsed(), time.monotonic() - _drain_started_at, timed_out, @@ -8075,6 +8293,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew self._running_agent_count(), _cron_at_start, self._active_cron_job_count(), + _api_at_start, + self._active_api_run_count(), ) if not timed_out: @@ -8084,7 +8304,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew for _sk in _pre_drain_keys: if _sk not in self._running_agents: try: - self.session_store.clear_resume_pending(_sk) + await self.async_session_store.clear_resume_pending(_sk) except Exception as _e: logger.debug( "clear_resume_pending after drain failed for %s: %s", @@ -8093,11 +8313,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if timed_out: logger.warning( - "Gateway drain timed out after %.1fs with %d active agent(s) " - "and %d in-flight cron job(s); interrupting remaining work.", + "Gateway drain timed out after %.1fs with %d active agent(s), " + "%d in-flight cron job(s), and %d api_server run(s); " + "interrupting remaining work.", timeout, self._running_agent_count(), self._active_cron_job_count(), + self._active_api_run_count(), ) # Mark forcibly-interrupted sessions as resume_pending BEFORE # interrupting the agents. This preserves each session's @@ -8127,7 +8349,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if _agent is _AGENT_PENDING_SENTINEL: continue try: - self.session_store.mark_resume_pending(_sk, _resume_reason) + await self.async_session_store.mark_resume_pending(_sk, _resume_reason) except Exception as _e: logger.debug( "mark_resume_pending failed for %s: %s", @@ -8439,6 +8661,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Record served profiles in runtime status for `hermes status`. try: from gateway.status import write_runtime_status + from gateway.pairing import PairingStore served = [active] + sorted(self._profile_adapters.keys()) # Per-profile PairingStores so authz_mixin can route pairing # checks to the right whitelist. The active profile gets a store @@ -8564,6 +8787,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if isinstance(val, str) and val.strip(): token = val.strip() break + # Many adapters (e.g. Discord) store the token on their `config` + # sub-object rather than directly on the adapter. Without this lookup + # those adapters all return None here, the same-token conflict check + # is silently skipped, and every profile's adapter for that platform + # starts polling the same bot token — producing a per-message race + # for which adapter answers. See test_reads_config_token. + if not token: + cfg = getattr(adapter, "config", None) + if cfg is not None: + for attr in ("token", "bot_token"): + val = getattr(cfg, attr, None) + if isinstance(val, str) and val.strip(): + token = val.strip() + break if not token: config = getattr(adapter, "config", None) val = getattr(config, "token", None) @@ -8600,12 +8837,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if platform_registry.is_registered(platform.value): adapter = platform_registry.create_adapter(platform.value, config) if adapter is not None: - # Adapters that need a back-reference to the gateway runner - # (e.g. for cross-platform admin alerts) declare a - # ``gateway_runner`` attribute. Inject it after creation so - # plugin adapters don't need a custom factory signature. - if hasattr(adapter, "gateway_runner"): - adapter.gateway_runner = self + # Inject a back-reference to the gateway runner so every + # adapter can (a) deliver cross-platform admin alerts and + # (b) resolve inbound profile routing through + # ``runner._profile_name_for_source``. Unconditional: + # ``BasePlatformAdapter`` declares ``gateway_runner``, so + # this reaches ALL platforms (not just the ones that + # pre-declared it), making profile routing platform-generic. + adapter.gateway_runner = self return adapter # Registered but failed to instantiate — don't silently fall # through to built-ins (there are none for plugin platforms). @@ -9013,6 +9252,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "Gateway intercepted clarify text response (session=%s, id=%s)", _quick_key, _pending_clarify.clarify_id, ) + # The clarify callback pauses the platform typing/status + # indicator while waiting so Slack users can type their + # answer. The active agent resumes as soon as this reply + # resolves the wait, so re-enable its indicator here too. + # Without this, Slack stays silent until the independent + # long-running heartbeat fires (three minutes by default). + _clarify_adapter = self._adapter_for_source(source) + if _clarify_adapter: + try: + _clarify_adapter.resume_typing_for_chat(source.chat_id) + except Exception: + logger.debug( + "Failed to resume typing after clarify response", + exc_info=True, + ) # Acknowledge with empty string so adapters that emit # the agent's response don't double-post. The agent # itself will produce the next user-facing message. @@ -9500,7 +9754,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # the id out from under it, forking orphaned compression # siblings. Demote to queue semantics so the follow-up waits # for the in-flight compression + rotation to land. - if self._session_has_compression_in_flight(_quick_key): + if await self._session_has_compression_in_flight(_quick_key): logger.info( "PRIORITY interrupt demoted to queue for session %s " "because context compression is in flight (#56391)", @@ -9541,7 +9795,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if isinstance(quick_commands, dict) and command in quick_commands: qcmd = quick_commands[command] if qcmd.get("type") == "alias": - target = qcmd.get("target", "").strip() + target = (qcmd.get("target") or "").strip() if target: target = target if target.startswith("/") else f"/{target}" target_command = target.lstrip("/") @@ -9953,7 +10207,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew else: return f"Quick command '/{command}' has no command defined." elif qcmd.get("type") == "alias": - target = qcmd.get("target", "").strip() + target = (qcmd.get("target") or "").strip() if target: target = target if target.startswith("/") else f"/{target}" target_command = target.lstrip("/") @@ -10207,7 +10461,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # on error. Let the user drive the next turn. if _final_text.strip(): try: - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) except Exception: session_entry = None if session_entry is not None: @@ -10298,7 +10552,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew thread_sessions_per_user=_thread_sessions_per_user, ) if _is_shared_multi_user and source.user_name: - message_text = f"[{source.user_name}] {message_text}" + # source.user_name is the platform display name — attacker- + # influenceable on any platform that lets participants set their + # own name. Neutralize embedded newlines/control chars before + # interpolating it into every message in the shared session, or + # a hostile name can masquerade as a fake markdown section + # (mirrors the same field's treatment in + # build_session_context_prompt via _format_untrusted_prompt_value). + _safe_user_name = neutralize_untrusted_inline_text(source.user_name) + message_text = f"[{_safe_user_name}] {message_text}" # Prepend channel context from history backfill (if any). This # happens after sender-prefix so the prefix only applies to the @@ -10523,8 +10785,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew from agent.model_metadata import get_model_context_length_async _msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~")) - _msg_runtime = _resolve_runtime_agent_kwargs() _msg_config_ctx = None + _msg_cfg = None + _msg_model_cfg = {} + _msg_custom_providers = [] try: _msg_cfg = _load_gateway_config() _msg_model_cfg = _msg_cfg.get("model", {}) @@ -10532,13 +10796,57 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _msg_raw_ctx = _msg_model_cfg.get("context_length") if _msg_raw_ctx is not None: _msg_config_ctx = int(_msg_raw_ctx) + try: + from hermes_cli.config import get_compatible_custom_providers + + _msg_custom_providers = get_compatible_custom_providers(_msg_cfg) + except Exception: + _msg_custom_providers = _msg_cfg.get("custom_providers") or [] except Exception: pass + # Resolve the session's actual model/provider/base_url the + # same way the hygiene compression block does (~11080). + # GatewayRunner has no self._model/self._base_url attrs + # (that was copy-pasted from HermesCLI, which does carry + # self.model/self.base_url), so using them here always raised + # AttributeError, silently caught below, meaning this feature + # never ran. + _msg_model, _msg_runtime = self._resolve_session_agent_runtime( + source=source, + session_key=session_key, + user_config=_msg_cfg, + ) + _msg_base_url = _msg_runtime.get("base_url") or "" + # A global model.context_length belongs to the configured + # model, not a session /model or channel override. Prefer a + # matching per-custom-provider model limit when available. + _msg_configured_model = ( + _msg_model_cfg.get("default") or _msg_model_cfg.get("model") + if isinstance(_msg_model_cfg, dict) + else _msg_model_cfg + ) + if _msg_model != _msg_configured_model: + _msg_config_ctx = None + if _msg_custom_providers and _msg_base_url: + try: + from hermes_cli.config import get_custom_provider_context_length + + _msg_custom_ctx = get_custom_provider_context_length( + model=_msg_model, + base_url=_msg_base_url, + custom_providers=_msg_custom_providers, + ) + if _msg_custom_ctx: + _msg_config_ctx = _msg_custom_ctx + except Exception: + pass _msg_ctx_len = await get_model_context_length_async( - self._model, - base_url=self._base_url or _msg_runtime.get("base_url") or "", + _msg_model, + base_url=_msg_base_url, api_key=_msg_runtime.get("api_key") or "", config_context_length=_msg_config_ctx, + provider=_msg_runtime.get("provider") or "", + custom_providers=_msg_custom_providers, ) _ctx_result = await preprocess_context_references_async( message_text, @@ -10557,10 +10865,35 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if _ctx_result.expanded: message_text = _ctx_result.message except Exception as exc: - logger.debug("@ context reference expansion failed: %s", exc) + logger.warning("@ context reference expansion failed: %s", exc) + logger.debug("@ context reference expansion failure detail", exc_info=True) return message_text + async def _prepare_profile_scoped_inbound_message_text( + self, + *, + event: MessageEvent, + source: SessionSource, + history: List[Dict[str, Any]], + session_key: Optional[str] = None, + ) -> Optional[str]: + """Run inbound preprocessing under the routed profile when multiplexed.""" + if getattr(getattr(self, "config", None), "multiplex_profiles", False): + with _profile_runtime_scope(self._resolve_profile_home_for_source(source)): + return await self._prepare_inbound_message_text( + event=event, + source=source, + history=history, + session_key=session_key, + ) + return await self._prepare_inbound_message_text( + event=event, + source=source, + history=history, + session_key=session_key, + ) + def _consume_pending_native_image_paths(self, session_key: str) -> List[str]: pending_native = getattr(self, "_pending_native_image_paths_by_session", None) if not pending_native: @@ -10588,6 +10921,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: pass + @property + def async_session_store(self) -> AsyncSessionStore: + """Return the single async facade for this runner's SessionStore.""" + facade = getattr(self, "_async_session_store", None) + if facade is None or facade._store is not self.session_store: + facade = AsyncSessionStore(self.session_store) + self._async_session_store = facade + return facade + def _get_cached_session_source(self, session_key: str): if not session_key: return None @@ -10631,7 +10973,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: pass - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) session_key = session_entry.session_key pinned_session_id = str( (getattr(event, "metadata", None) or {}).get("gateway_session_id") or "" @@ -10663,7 +11005,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) return prior_session_id = session_entry.session_id - switched = self.session_store.switch_session(session_key, pinned_session_id) + switched = await self.async_session_store.switch_session(session_key, pinned_session_id) if switched is not None: session_entry = switched logger.info( @@ -10713,7 +11055,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # lane session is ended cleanly. Mutating session_entry in # place here created a split-brain state where the JSON # index pointed at one id but code downstream used another. - switched = self.session_store.switch_session(session_key, bound_session_id) + switched = await self.async_session_store.switch_session(session_key, bound_session_id) if switched is not None: session_entry = switched # If the stored binding pointed at a parent, rewrite it to the @@ -10901,7 +11243,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.warning("[Gateway] Failed to auto-load skill(s) %s: %s", _skill_names, e) # Load conversation history from transcript - history = self.session_store.load_transcript(session_entry.session_id) + history = await self.async_session_store.load_transcript(session_entry.session_id) # ----------------------------------------------------------------- # Session hygiene: auto-compress pathologically large transcripts @@ -11107,6 +11449,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ] if len(_hyg_msgs) >= 4: + _hyg_session_db = getattr(self._session_db, "_db", self._session_db) _hyg_agent = AIAgent( **_hyg_runtime, model=_hyg_model, @@ -11115,15 +11458,31 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew skip_memory=True, enabled_toolsets=["memory"], session_id=session_entry.session_id, - session_db=getattr(self._session_db, "_db", self._session_db), + session_db=_hyg_session_db, ) try: - # The hygiene agent rotates the session - # forward to a continuation id that becomes - # the gateway session's live row. It must - # never finalize on close() — close() would - # end the newly rotated session the gateway - # entry now points at. + # Gateway hygiene runs before the user turn + # starts and already owns the session binding. + # Prefer in-place compaction here: it archives + # old rows under the same session id instead of + # minting a continuation child that then has to + # be published back to SessionStore/topic + # bindings. If no SessionDB is available, + # compress_context leaves this flag false and + # the guard below preserves the transcript. + _hyg_agent.compression_in_place = True + _bind_hyg_state = getattr( + getattr(_hyg_agent, "context_compressor", None), + "bind_session_state", + None, + ) + if callable(_bind_hyg_state): + _bind_hyg_state( + _hyg_session_db, + session_entry.session_id, + ) + # It must never finalize on close() — close() + # would end the live gateway session row. _hyg_agent._end_session_on_close = False _hyg_agent._print_fn = lambda *a, **kw: None @@ -11147,7 +11506,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) if _hyg_rotated: session_entry.session_id = _hyg_new_sid - self.session_store._save() + await self.async_session_store._save() await asyncio.to_thread( self._sync_telegram_topic_binding, source, session_entry, @@ -11155,17 +11514,27 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) # Only rewrite the transcript when rotation produced - # a NEW session id OR in-place compaction succeeded. + # a NEW session id. In-place compaction does NOT + # need a rewrite: archive_and_compact() has already + # soft-archived the previous active rows and inserted + # the compacted messages as the new active set inside + # _compress_context(). Calling rewrite_transcript() + # after in-place compaction would invoke + # replace_messages(active_only=False) which DELETEs + # ALL rows — including the archived turns that + # archive_and_compact() deliberately preserved + # (silent data loss, #61145). + # # The danger this guards against (mirrors the - # /compress fix #44794/#39704): the hygiene agent is - # built WITHOUT a session_db, so _compress_context - # cannot rotate — if it also wasn't in-place, the - # session_id is unchanged for a FAILURE reason, and an - # unconditional rewrite_transcript() would DELETE the - # original messages and replace them with only the - # compressed summary (permanent data loss, #21301). - if _hyg_rotated or _hyg_in_place: - self.session_store.rewrite_transcript( + # /compress fix #44794/#39704): if _compress_context + # returns a summary but neither rotates nor completes + # archive_and_compact(), the session_id is unchanged + # for a FAILURE reason, and an unconditional + # rewrite_transcript() would DELETE the original + # messages and replace them with only the compressed + # summary (permanent data loss, #21301). + if _hyg_rotated: + await self.async_session_store.rewrite_transcript( session_entry.session_id, _compressed ) # Reset stored token count — transcript rewritten @@ -11175,6 +11544,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _new_tokens = estimate_messages_tokens_rough( _compressed ) + elif _hyg_in_place: + # archive_and_compact() already persisted the + # compacted transcript inside _compress_context. + # Reset counts to match the new active set. + session_entry.last_prompt_tokens = 0 + history = _compressed + _new_count = len(_compressed) + _new_tokens = estimate_messages_tokens_rough( + _compressed + ) else: # No rewrite happened — transcript preserved # unchanged, so the post-compression counts equal @@ -11272,7 +11651,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) # First-message onboarding -- only on the very first interaction ever - if not history and not self.session_store.has_any_sessions(): + if not history and not await self.async_session_store.has_any_sessions(): # Default first-contact note: a brief self-introduction. _intro_note = ( "\n\n[System note: This is the user's very first message ever. " @@ -11357,7 +11736,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # attachments (documents, audio, etc.) are not sent to the vision # tool even when they appear in the same message. # ----------------------------------------------------------------- - message_text = await self._prepare_inbound_message_text( + message_text = await self._prepare_profile_scoped_inbound_message_text( event=event, source=source, history=history, @@ -11444,10 +11823,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew persist_user_timestamp=persist_user_timestamp, ) - # Stop persistent typing indicator now that the agent is done + # Stop persistent typing indicator now that the agent is done. + # Slack AI status is scoped to a thread/workspace, so preserve the + # same routing metadata used by the response delivery path. try: _typing_adapter = self._adapter_for_source(source) - if _typing_adapter and hasattr(_typing_adapter, "stop_typing"): + _stop_with_metadata = getattr( + type(_typing_adapter), "_stop_typing_with_metadata", None + ) + _stop_typing = getattr(type(_typing_adapter), "stop_typing", None) + if _typing_adapter and callable(_stop_with_metadata): + await _typing_adapter._stop_typing_with_metadata( + source.chat_id, + self._thread_metadata_for_source( + source, self._reply_anchor_for_event(event) + ), + ) + elif _typing_adapter and callable(_stop_typing): await _typing_adapter.stop_typing(source.chat_id) except Exception: pass @@ -11469,6 +11861,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew return None response = agent_result.get("final_response") or "" + # Hidden-reasoning-only retry exhaustion: the loop's sentinel text + # ("Codex response remained incomplete after 3 continuation + # attempts") doubles as final_response, so it would be delivered + # verbatim into the channel — where peer agents can ingest it as a + # completed assistant turn (#51628). Blank it here so the normal + # empty-response handling (and the suppression below) applies. + if _is_gateway_hidden_reasoning_incomplete_turn(agent_result): + response = "" try: from gateway.response_filters import is_intentional_silence_agent_result _intentional_silence = is_intentional_silence_agent_result( @@ -11516,7 +11916,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if session_key and _should_clear_resume_pending_after_turn(agent_result): self._clear_restart_failure_count(session_key) try: - self.session_store.clear_resume_pending(session_key) + await self.async_session_store.clear_resume_pending(session_key) except Exception as _e: logger.debug( "clear_resume_pending failed for %s: %s", @@ -11538,8 +11938,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id: if session_entry.session_id == _run_start_session_id: session_entry.session_id = agent_result["session_id"] - self.session_store._save() - self.session_store._record_gateway_session_peer( + await self.async_session_store._save() + await self.async_session_store._record_gateway_session_peer( session_entry.session_id, session_key, source, @@ -11699,6 +12099,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # forgets what was just asked. Persist the user turn so the # conversation is preserved. (#7100) agent_failed_early = bool(agent_result.get("failed")) + hidden_reasoning_incomplete = _is_gateway_hidden_reasoning_incomplete_turn( + agent_result + ) _err_str_for_classify = str(agent_result.get("error", "")).lower() # Use specific multi-word phrases (not bare "exceed" or "token") # to avoid false positives on transient errors like "rate limit @@ -11727,6 +12130,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "message so conversation context is preserved on retry.", session_entry.session_id, ) + elif hidden_reasoning_incomplete: + logger.warning( + "Suppressing hidden-reasoning-only incomplete gateway turn " + "for session %s: %s", + session_entry.session_id, + agent_result.get("error", "processing incomplete"), + ) # When compression is exhausted, the session is permanently too # large to process. Auto-reset it so the next message starts @@ -11737,7 +12147,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "Auto-resetting session %s after compression exhaustion.", session_entry.session_id, ) - new_entry = self.session_store.reset_session(session_key) + new_entry = await self.async_session_store.reset_session(session_key) self._evict_cached_agent(session_key) self._session_model_overrides.pop(session_key, None) self._set_session_reasoning_override(session_key, None) @@ -11781,7 +12191,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew pass # Skip all transcript writes — don't grow a broken session elif not history: tool_defs = agent_result.get("tools", []) - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, { "role": "session_meta", @@ -11810,11 +12220,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # entries that were stripped before the agent saw them. if is_context_overflow_failure: pass # handled above — skip all transcript writes - elif agent_failed_early: + elif agent_failed_early or hidden_reasoning_incomplete: # Transient failure (429/timeout/5xx): persist only the user # message so the next message can load a transcript that # reflects what was said. Skip the assistant error text since - # it's a gateway-generated hint, not model output. (#7100) + # it's a gateway-generated hint, not model output. Hidden- + # reasoning-only incomplete turns follow the same persistence + # rule so peer-agent channels don't ingest them as completed + # assistant turns. (#7100, #51628) _user_entry = { "role": "user", "content": ( @@ -11835,7 +12248,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # after transient failures). #47237 _skip_persist = ( event.message_id - and self.session_store.has_platform_message_id( + and await self.async_session_store.has_platform_message_id( session_entry.session_id, str(event.message_id) ) ) @@ -11846,7 +12259,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew event.message_id, session_entry.session_id, ) else: - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, _user_entry, skip_db=agent_persisted, @@ -11872,13 +12285,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew } if event.message_id: _user_entry["message_id"] = str(event.message_id) - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, _user_entry, skip_db=agent_persisted, ) if response: - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, {"role": "assistant", "content": response, "timestamp": ts}, skip_db=agent_persisted, @@ -11903,7 +12316,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ): entry["message_id"] = str(event.message_id) _user_msg_id_attached = True - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, entry, skip_db=agent_persisted, ) @@ -11911,7 +12324,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Token counts and model are now persisted by the agent directly. # Keep only last_prompt_tokens here for context-window tracking and # compression decisions. - self.session_store.update_session( + await self.async_session_store.update_session( session_entry.session_key, last_prompt_tokens=agent_result.get("last_prompt_tokens", 0), ) @@ -11993,10 +12406,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew return response except Exception as e: - # Stop typing indicator on error too + # Stop typing indicator on error too, retaining Slack thread/workspace + # routing so a failed turn cannot leave its status visible. try: _err_adapter = self._adapter_for_source(source) - if _err_adapter and hasattr(_err_adapter, "stop_typing"): + _stop_with_metadata = getattr( + type(_err_adapter), "_stop_typing_with_metadata", None + ) + _stop_typing = getattr(type(_err_adapter), "stop_typing", None) + if _err_adapter and callable(_stop_with_metadata): + await _err_adapter._stop_typing_with_metadata( + source.chat_id, + self._thread_metadata_for_source( + source, self._reply_anchor_for_event(event) + ), + ) + elif _err_adapter and callable(_stop_typing): await _err_adapter.stop_typing(source.chat_id) except Exception: pass @@ -12011,7 +12436,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if 'message_text' in locals() and message_text is not None and session_entry is not None: _already_persisted = False try: - _recent_transcript = self.session_store.load_transcript(session_entry.session_id) + _recent_transcript = await self.async_session_store.load_transcript(session_entry.session_id) except Exception: _recent_transcript = [] for _msg in reversed(_recent_transcript[-10:]): @@ -12039,7 +12464,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew } if getattr(event, "message_id", None): _user_entry["message_id"] = str(event.message_id) - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, _user_entry, ) @@ -12494,7 +12919,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: return 20 - def _get_goal_manager_for_event(self, event: "MessageEvent"): + async def _get_goal_manager_for_event(self, event: "MessageEvent"): """Return a GoalManager bound to the session for this gateway event. Returns ``(manager, session_entry)`` or ``(None, None)`` if the @@ -12506,7 +12931,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.debug("goal manager unavailable: %s", exc) return None, None try: - session_entry = self.session_store.get_or_create_session(event.source) + session_entry = await self.async_session_store.get_or_create_session(event.source) except Exception as exc: logger.debug("goal manager: session lookup failed: %s", exc) return None, None @@ -13177,7 +13602,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew pr = self._provider_routing max_iterations = _current_max_iterations() - reasoning_config = self._resolve_session_reasoning_config(source=source) + reasoning_config = self._resolve_session_reasoning_config( + source=source, model=model + ) self._reasoning_config = reasoning_config self._service_tier = self._load_service_tier() turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs) @@ -13227,7 +13654,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew chat_type=source.chat_type, thread_id=source.thread_id, session_db=getattr(self._session_db, "_db", self._session_db), - fallback_model=self._fallback_model, + # Reload from disk — do not reuse the startup snapshot (#60955). + fallback_model=self._refresh_fallback_model(), ) try: return agent.run_conversation( @@ -13948,8 +14376,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "content": f"[IMPORTANT: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]", } try: - session_entry = self.session_store.get_or_create_session(event.source) - self.session_store.append_to_transcript( + session_entry = await self.async_session_store.get_or_create_session(event.source) + await self.async_session_store.append_to_transcript( session_entry.session_id, reload_msg ) except Exception: @@ -14154,13 +14582,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew reply_to_message_id: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Build the metadata dict platforms need for thread-aware replies.""" - return self._thread_metadata_for_target( + metadata = self._thread_metadata_for_target( getattr(source, "platform", None), getattr(source, "chat_id", None), getattr(source, "thread_id", None), chat_type=getattr(source, "chat_type", None), reply_to_message_id=reply_to_message_id or getattr(source, "message_id", None), ) + if getattr(source, "platform", None) == Platform.SLACK: + team_id = getattr(source, "scope_id", None) + if team_id: + metadata = dict(metadata or {}) + metadata["slack_team_id"] = str(team_id) + return metadata def _thread_metadata_for_target( self, @@ -15220,11 +15654,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew user_name=str(evt.get("user_name") or "").strip() or None, ) - async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None: - """Inject a watch-pattern notification as a synthetic message event. + async def _inject_watch_notification( + self, synth_text: str, evt: dict, + ) -> Optional[bool]: + """Inject a watch/completion notification as a synthetic message event. - Routing must come from the queued watch event itself, not from whatever + Routing must come from the queued event itself, not from whatever foreground message happened to be active when the queue was drained. + Returns ``True`` after adapter acceptance, ``False`` after a retryable + adapter failure, and ``None`` when the event has no gateway route. This + is not a transactional boundary: a process crash after adapter + acceptance can still cause durable at-least-once replay. """ source = self._build_process_event_source(evt) if not source: @@ -15232,7 +15672,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "Dropping watch notification with no routing metadata for process %s", evt.get("session_id", "unknown"), ) - return + return None platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform) adapter = None for p, a in self.adapters.items(): @@ -15240,7 +15680,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew adapter = a break if not adapter: - return + return None try: metadata = {} parent_session_id = str(evt.get("parent_session_id") or "").strip() @@ -15261,8 +15701,118 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew source.thread_id, ) await adapter.handle_message(synth_event) + return True except Exception as e: logger.error("Watch notification injection error: %s", e) + return False + + @staticmethod + def _completion_delivery_identity(evt: dict) -> Optional[tuple[str, str, object]]: + """Return a producer-stable identity when one is available. + + Delegation UUIDs identify one producer completion. Process session IDs + are normally unique too, but include the persisted spawn epoch so an + explicitly reused ID represents a distinct process incarnation. Legacy + process events without ``started_at`` are delivered without deduplication + rather than risking suppression of a real completion. + """ + evt_type = str(evt.get("type") or "") + if evt_type == "async_delegation": + producer_id = str(evt.get("delegation_id") or "") + return (evt_type, producer_id, "") if producer_id else None + if evt_type == "completion": + producer_id = str(evt.get("session_id") or "") + started_at = evt.get("started_at") + if producer_id and started_at is not None: + return (evt_type, producer_id, started_at) + return None + + async def _deliver_completion_notification( + self, synth_text: str, evt: dict, + ) -> Optional[bool]: + """Deliver once per live gateway, or return False for a retry. + + ``True`` means this caller reached adapter acceptance, ``False`` means + injection failed and the claim was released for retry, and ``None`` + means either another same-lifecycle caller owns/delivered the producer + event or the event has no gateway route. No cross-process exactly-once + guarantee is claimed. + """ + identity = self._completion_delivery_identity(evt) + durable_claim_id = "" + durable_delegation_id = "" + if evt.get("type") == "async_delegation": + durable_delegation_id = str(evt.get("delegation_id") or "") + if durable_delegation_id: + try: + from tools.async_delegation import claim_completion_delivery + + durable_claim_id = f"gateway:{id(self)}:{__import__('uuid').uuid4().hex}" + if not claim_completion_delivery( + durable_delegation_id, durable_claim_id, + ): + return None + except Exception as exc: + logger.warning( + "Could not claim durable async completion %s: %s", + durable_delegation_id, exc, + ) + return False + if identity is not None: + with self._completion_delivery_lock: + if ( + identity in self._completion_deliveries_inflight + or identity in self._completion_deliveries_delivered + ): + return None + self._completion_deliveries_inflight.add(identity) + + accepted = False + try: + injection_result = await self._inject_watch_notification(synth_text, evt) + if injection_result is not True: + return injection_result + accepted = True + + if identity is not None: + with self._completion_delivery_lock: + self._completion_deliveries_inflight.discard(identity) + self._completion_deliveries_delivered[identity] = None + while ( + len(self._completion_deliveries_delivered) + > self._completion_delivery_retention + ): + self._completion_deliveries_delivered.popitem(last=False) + + # If the durable async-delegation producer branch is present, its + # SQLite row remains the authoritative replay state. Acknowledge it + # after adapter acceptance; this gateway keeps no parallel ledger. + if durable_claim_id: + try: + from tools.async_delegation import complete_completion_delivery + + complete_completion_delivery( + durable_delegation_id, durable_claim_id, + ) + except Exception as exc: + logger.warning( + "Could not acknowledge durable async completion %s: %s", + durable_delegation_id, exc, + ) + return True + finally: + if identity is not None and not accepted: + with self._completion_delivery_lock: + self._completion_deliveries_inflight.discard(identity) + if durable_claim_id and not accepted: + try: + from tools.async_delegation import release_completion_delivery + + release_completion_delivery( + durable_delegation_id, durable_claim_id, + ) + except Exception: + logger.debug("Could not release durable completion claim", exc_info=True) def _enrich_async_delegation_routing(self, evt: dict) -> None: """Fill platform/chat_id/thread_id/chat_type on an async-delegation event. @@ -15325,8 +15875,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if not synth_text: continue try: - await self._inject_watch_notification(synth_text, evt) + delivered = await self._deliver_completion_notification(synth_text, evt) + if delivered is False: + _pr.completion_queue.put(evt) except Exception as e: + _pr.completion_queue.put(evt) logger.error("Async delegation injection error: %s", e) except Exception as e: logger.debug("Async delegation watcher error: %s", e) @@ -15392,8 +15945,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # (#10156) — a status check must not suppress this delivery turn. from tools.process_registry import format_process_notification, process_registry as _pr_check if agent_notify and not _pr_check.is_completion_consumed(session_id): + from agent.redact import redact_terminal_output from tools.ansi_strip import strip_ansi + _command = getattr(session, "command", "") or "" _raw = strip_ansi(session.output_buffer) if session.output_buffer else "" + _raw = redact_terminal_output(_raw, _command) + _command = _redact_gateway_user_facing_secrets(_command) # Truncate at line boundaries so notifications never start # mid-line (fixes #23284). Keep the last ~2000 chars but # snap to the nearest preceding newline, then prepend a @@ -15406,57 +15963,34 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _out = f"[… output truncated — showing last {len(_tail)} chars]\n{_tail}" else: _out = _raw - synth_text = format_process_notification({ + completion_evt = { "type": "completion", - "session_id": session_id, - "command": session.command, - "exit_code": session.exit_code, - "completion_reason": getattr(session, "completion_reason", "exited"), - "termination_source": getattr(session, "termination_source", ""), - "output": _out, - }) - if not synth_text: - break - source = self._build_process_event_source({ "session_id": session_id, "session_key": session_key, "platform": platform_name, + "chat_type": watcher.get("chat_type", ""), "chat_id": chat_id, "thread_id": thread_id, "user_id": user_id, "user_name": user_name, - }) - if not source: - logger.warning( - "Dropping completion notification with no routing metadata for process %s", - session_id, - ) + "message_id": message_id, + "started_at": getattr(session, "started_at", None), + "command": _command, + "exit_code": session.exit_code, + "completion_reason": getattr(session, "completion_reason", "exited"), + "termination_source": getattr(session, "termination_source", ""), + "output": _out, + } + synth_text = format_process_notification(completion_evt) + if not synth_text: break - - adapter = None - for p, a in self.adapters.items(): - if p == source.platform: - adapter = a - break - if adapter and source.chat_id: - try: - synth_event = MessageEvent( - text=synth_text, - message_type=MessageType.TEXT, - source=source, - internal=True, - message_id=message_id, - ) - logger.info( - "Process %s finished — injecting agent notification for session %s chat=%s thread=%s", - session_id, - session_key, - source.chat_id, - source.thread_id, - ) - await adapter.handle_message(synth_event) - except Exception as e: - logger.error("Agent notify injection error: %s", e) + delivered = await self._deliver_completion_notification( + synth_text, completion_evt, + ) + if delivered is False: + # The process remains terminal; retry after failed + # adapter injection instead of suppressing the result. + continue break # --- Normal text-only notification --- @@ -15958,8 +16492,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew running_agent.interrupt(interrupt_reason) self._invalidate_session_run_generation(session_key, reason=invalidation_reason) adapter = self._adapter_for_source(source) - if adapter and hasattr(adapter, "interrupt_session_activity"): - await adapter.interrupt_session_activity(session_key, source.chat_id) + interrupt_session_activity = getattr( + type(adapter), "interrupt_session_activity", None + ) + if adapter and callable(interrupt_session_activity): + metadata = self._thread_metadata_for_source(source) + try: + params = inspect.signature(interrupt_session_activity).parameters + accepts_metadata = "metadata" in params or any( + param.kind is inspect.Parameter.VAR_KEYWORD + for param in params.values() + ) + except (TypeError, ValueError): + accepts_metadata = False + if accepts_metadata: + await adapter.interrupt_session_activity( + session_key, source.chat_id, metadata=metadata + ) + else: + await adapter.interrupt_session_activity(session_key, source.chat_id) if adapter and hasattr(adapter, "get_pending_message"): adapter.get_pending_message(session_key) # consume and discard self._pending_messages.pop(session_key, None) @@ -16417,7 +16968,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if url: return url.rstrip("/") cfg = _load_gateway_config() - url = (cfg.get("gateway") or {}).get("proxy_url", "").strip() + url = (cfg.get("gateway") or {}).get("proxy_url") + url = (url or "").strip() if url: return url.rstrip("/") return None @@ -16734,7 +17286,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew event_message_id: Optional[str] = None, channel_prompt: Optional[str] = None, moa_config: Optional[dict] = None, - persist_user_message: Optional[str] = None, + persist_user_message: Optional[Any] = None, persist_user_timestamp: Optional[float] = None, ) -> Dict[str, Any]: """Profile-scoping wrapper around the agent run. @@ -16767,19 +17319,108 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew persist_user_timestamp=persist_user_timestamp, ) + def _profile_name_for_source(self, source: SessionSource) -> Optional[str]: + """Resolve the profile name for an inbound source via configured routes. + + Returns ``None`` when multiplexing is off, no routes are configured, or + no route matches. Callers (``build_source``, + ``_resolve_profile_home_for_source``) treat ``None`` as "use the + default/active profile". When ``gateway.profile_routes`` is configured, + the most specific matching route wins (guild < channel < thread). See + :mod:`gateway.profile_routing` for matching rules. + + Gated on ``gateway.multiplex_profiles``: routing stamps + ``source.profile``, which selects the session-key namespace and batch + keys — but the profile-scoped agent run only activates under + multiplexing. Without this gate, a configured route with multiplexing + off would namespace batch/session keys by profile while the agent + still runs in ``agent:main``, splitting the two out of agreement. + """ + config = getattr(self, "config", None) + if not getattr(config, "multiplex_profiles", False): + return None + routes = getattr(config, "profile_routes", None) + if not routes: + return None + from gateway.profile_routing import match_profile_route + try: + matched = match_profile_route( + routes, + platform=source.platform.value, + guild_id=getattr(source, "guild_id", None), + chat_id=source.chat_id, + thread_id=getattr(source, "thread_id", None), + parent_chat_id=getattr(source, "parent_chat_id", None), + ) + except Exception: + logger.warning( + "Profile route matching failed for %s/%s, falling back to default", + source.platform, source.chat_id, exc_info=True, + ) + return None + if matched: + return matched.profile + logger.debug( + "No profile route matched: platform=%s chat_id=%s thread_id=%s parent_chat_id=%s", + source.platform.value, source.chat_id, + getattr(source, "thread_id", None), getattr(source, "parent_chat_id", None), + ) + return None + def _resolve_profile_home_for_source(self, source: SessionSource) -> "Path": """Resolve which profile's HERMES_HOME should serve this inbound source. - Prefers the profile the source was routed to (``source.profile`` — set - by the /p/<profile>/ URL prefix or a per-credential adapter), falling - back to the active profile (the multiplexer's own home). + Resolution order: + 1. ``source.profile`` — set by /p/<profile>/ URL prefix, per-credential + adapter ownership, OR profile_routes matching at ``build_source`` time. + 2. ``_profile_name_for_source`` — re-run routing here as a defensive + fallback for sources that bypass ``build_source``. + 3. The active profile (the multiplexer's own home). """ - from hermes_cli.profiles import get_active_profile_name, get_profile_dir + from hermes_cli.profiles import ( + get_active_profile_name, + get_profile_dir, + profile_exists, + ) + from hermes_constants import get_hermes_home + + # Track whether a profile was explicitly requested (vs. falling back to default) + explicit_profile = None try: - name = (source.profile or "").strip() or get_active_profile_name() or "default" - return get_profile_dir(name) + name = (source.profile or "").strip() + if name: + explicit_profile = name # User explicitly set this profile + if not name: + name = self._profile_name_for_source(source) + if name: + explicit_profile = name # Routing explicitly set this profile + if not name: + name = get_active_profile_name() or "default" + + profile_dir = get_profile_dir(name) + # Warn if an explicit profile doesn't exist on disk + if explicit_profile and not profile_exists(name): + logger.warning( + "Profile %r does not exist for source %s/%s (guild_id=%s), " + "falling back to global HERMES_HOME", + explicit_profile, + source.platform.value, + source.chat_id, + getattr(source, "guild_id", None), + ) + return get_hermes_home() + return profile_dir except Exception: - from hermes_constants import get_hermes_home + # Catch normalization errors, path errors, etc. + logger.warning( + "Failed to resolve profile directory for source %s/%s (guild_id=%s), " + "falling back to global HERMES_HOME: %s", + source.platform.value, + source.chat_id, + getattr(source, "guild_id", None), + explicit_profile or "(no profile)", + exc_info=True, + ) return get_hermes_home() async def _run_agent_inner( @@ -16795,7 +17436,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew event_message_id: Optional[str] = None, channel_prompt: Optional[str] = None, moa_config: Optional[dict] = None, - persist_user_message: Optional[str] = None, + persist_user_message: Optional[Any] = None, persist_user_timestamp: Optional[float] = None, ) -> Dict[str, Any]: """ @@ -17826,6 +18467,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew reasoning_config = self._resolve_session_reasoning_config( source=source, session_key=session_key, + model=model, ) self._reasoning_config = reasoning_config self._service_tier = self._load_service_tier() @@ -17962,6 +18604,39 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _cache_lock = getattr(self, "_agent_cache_lock", None) _cache = getattr(self, "_agent_cache", None) + # Peek at the cached entry's snapshot session_id (if any) so we can + # check, OUTSIDE the cache lock, whether THAT session_id is a DEAD + # session in state.db. This closes a gap in the #54947 fix: that + # fix treats "cached session_id != current session_id" as an + # intentional /resume-style switch and reuses the agent unchanged. + # But the #54878 self-heal produces the exact same tuple shape + # when it recovers a routing key away from a session that was + # already ended — the cached AIAgent still belongs to the DEAD + # session, not a valid sibling conversation. Reusing it lets that + # turn's post-run "session split" sync write the routing key + # straight back onto the dead session_id, undoing the self-heal + # and looping every message until an interrupt happens to race in + # first (the #54878 x #54947 interaction — no existing upstream + # issue tracks this combination as of 2026-07-12). + _peek_cached_sid = None + if _cache_lock and _cache is not None: + with _cache_lock: + _peek_entry = _cache.get(session_key) + if _peek_entry and len(_peek_entry) > 3: + _peek_cached_sid = _peek_entry[3] + _cached_sid_is_dead = False + if ( + _peek_cached_sid is not None + and session_id is not None + and _peek_cached_sid != session_id + ): + try: + _cached_sid_is_dead = self.session_store._is_session_ended_in_db( + _peek_cached_sid + ) + except Exception: + _cached_sid_is_dead = False + # Detect cross-process writes: when another process (e.g. hermes # dashboard) appends to the same session in the shared SessionDB, # the cached agent's in-memory transcript becomes stale. Compare @@ -18001,7 +18676,50 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew and session_id is not None and _cached_sid != session_id ) - if ( + # Re-validate the OUTSIDE-lock dead-session peek + # against the tuple actually read under THIS lock — + # the cache entry could have been replaced between + # the peek and this lock acquisition, and a stale + # "dead" verdict must never be applied to a + # different (possibly live) cached agent. + _stale_dead_sid_reuse = ( + _session_id_mismatch + and _cached_sid_is_dead + and _cached_sid == _peek_cached_sid + ) + if _stale_dead_sid_reuse: + # #54878 x #54947 interaction: the routing key + # was just self-healed away from a session that + # state.db already marked ended, but the cached + # AIAgent here still belongs to that DEAD + # session_id. The #54947 "different session_id + # under the same key = intentional switch, reuse + # freely" rule does not hold here — this isn't a + # sibling conversation, it's a stale agent left + # over from before the self-heal. Reusing it lets + # this turn's post-run "session split" sync write + # the routing key straight back onto the dead + # session_id, undoing the self-heal and looping + # every message until an interrupt happens to + # race in first. Discard and rebuild fresh + # instead, same as a genuine cross-process write. + logger.info( + "Agent cache invalidated for session %s: " + "cached agent's session_id %s is ended in " + "state.db (stale self-heal artifact, " + "#54878 x #54947) — discarding instead of " + "reusing across the routing recovery", + session_key, _cached_sid, + ) + evicted = self._agent_cache.pop(session_key, None) + _ev_agent = evicted[0] if isinstance(evicted, tuple) and evicted else None + if _ev_agent and _ev_agent is not _AGENT_PENDING_SENTINEL: + # Same deferred-cleanup rationale as the + # cross-process branch below (#52197): don't + # block the event loop / cache lock on + # memory-provider shutdown or socket teardown. + _xproc_evicted_agent = _ev_agent + elif ( not _session_id_mismatch and _cached_mc is not None and _current_msg_count is not None @@ -18048,6 +18766,18 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.debug("Reusing cached agent for session %s", session_key) reused_cached_agent = True + # Lock released — refresh the fallback chain from disk for the + # reused agent OUTSIDE the cache lock (config.yaml read is disk + # I/O; the idle-sweep watcher contends on this lock and stalls + # Discord heartbeats — same reasoning as #52197). A chain + # configured after this agent was cached (or after gateway start) + # must reach the next turn (#60955). Per-session turn + # serialization (_running_agents) keeps this safe post-lock. + if reused_cached_agent and agent is not None: + self._apply_fallback_chain_to_agent( + agent, self._refresh_fallback_model(), + ) + # Lock released — now schedule cleanup of any cross-process-evicted # agent on a daemon thread so memory-provider shutdown / socket # teardown never blocks the gateway event loop or the cache lock @@ -18100,7 +18830,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew thread_id=source.thread_id, gateway_session_key=session_key, session_db=getattr(self._session_db, "_db", self._session_db), - fallback_model=self._fallback_model, + # Reload from disk — do not reuse the startup snapshot (#60955). + fallback_model=self._refresh_fallback_model(), ) if _cache_lock and _cache is not None: with _cache_lock: @@ -18421,6 +19152,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew session_key=_approval_session_key, description=desc, metadata=_status_thread_metadata, + allow_permanent=approval_data.get("allow_permanent", True), + smart_denied=approval_data.get("smart_denied", False), ), _loop_for_step, logger=logger, @@ -18445,13 +19178,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # can actually type (`!approve`) — typed "/" is blocked in # Slack threads and reserved by Matrix clients. _p = getattr(_status_adapter, "typed_command_prefix", "/") - cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd - msg = ( - f"⚠️ **Dangerous command requires approval:**\n" - f"```\n{cmd_preview}\n```\n" - f"Reason: {desc}\n\n" - f"Reply `{_p}approve` to execute, `{_p}approve session` to approve this pattern " - f"for the session, `{_p}approve always` to approve permanently, or `{_p}deny` to cancel." + msg = _format_exec_approval_fallback( + cmd, + desc, + _p, + allow_permanent=approval_data.get("allow_permanent", True), + smart_denied=approval_data.get("smart_denied", False), ) try: _approval_send_fut = safe_schedule_threadsafe( @@ -19690,7 +20422,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew session_key or "?", exc_info=True, ) - next_message = await self._prepare_inbound_message_text( + next_message = await self._prepare_profile_scoped_inbound_message_text( event=pending_event, source=next_source, history=updated_history, @@ -20613,13 +21345,21 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = # historical in-process 60s ticker; an external provider (e.g. chronos) # may arm a schedule and return. Pass the event loop so cron delivery can # use live adapters (E2EE support). - from cron.scheduler_provider import resolve_cron_scheduler + from cron.scheduler_provider import InProcessCronScheduler, resolve_cron_scheduler cron_stop = threading.Event() cron_provider = resolve_cron_scheduler() + cron_start_kwargs = {"adapters": runner.adapters, "loop": asyncio.get_running_loop()} + # External cron providers own their remote scheduling contract. Only the + # in-process ticker polls local due jobs, so only it receives the local + # external-drain dispatch gate. + if isinstance(cron_provider, InProcessCronScheduler): + cron_start_kwargs["can_dispatch"] = lambda: not ( + runner._draining or runner._external_drain_active + ) cron_thread = threading.Thread( target=cron_provider.start, args=(cron_stop,), - kwargs={"adapters": runner.adapters, "loop": asyncio.get_running_loop()}, + kwargs=cron_start_kwargs, daemon=True, name="cron-scheduler", ) diff --git a/gateway/session.py b/gateway/session.py index fb2e08f4299e..42745a4675e3 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -8,6 +8,7 @@ Handles: - Dynamic system prompt injection (agent knows its context) """ +import asyncio import hashlib import logging import os @@ -377,6 +378,28 @@ def _format_untrusted_prompt_value(value: Any, *, max_chars: int = _MAX_PROMPT_M return json.dumps(text, ensure_ascii=False) +def neutralize_untrusted_inline_text(value: Any, *, max_chars: int = _MAX_PROMPT_METADATA_CHARS) -> str: + """Collapse untrusted text to a single inert line, unquoted. + + Sibling of :func:`_format_untrusted_prompt_value` for call sites that must + preserve the surrounding format (e.g. an inline ``[Name] message turn`` + prefix) instead of a standalone ``**Label:** "value"`` line — JSON-quoting + would visibly change a well-behaved value's rendering there. + + Embedded newlines are the injection vector both helpers guard against: + they let an untrusted display name masquerade as a new markdown section + (a fake heading, an "## Override" block) inside content the model reads + every turn. Collapsing them to a single space keeps a normal value + byte-identical while making a hostile one visually inert. + """ + text = str(value).replace("\r\n", "\n").replace("\r", "\n").replace("\n", " ") + text = "".join(ch if ch >= " " or ch == "\t" else " " for ch in text) + text = " ".join(text.split()) + if max_chars and len(text) > max_chars: + text = text[: max_chars - 3] + "..." + return text + + def build_session_context_prompt( context: SessionContext, *, @@ -958,6 +981,30 @@ def build_session_key( return ":".join(key_parts) +class _SessionFlight: + def __init__(self) -> None: + self.event = threading.Event() + self.result: Optional["SessionEntry"] = None + self.error: Optional[BaseException] = None + + +class AsyncSessionStore: + """Async boundary for the synchronous, thread-safe SessionStore.""" + + def __init__(self, store: "SessionStore") -> None: + self._store = store + + def __getattr__(self, name: str): + attr = getattr(self._store, name) + if not callable(attr): + return attr + + async def _offloaded(*args, **kwargs) -> Any: + return await asyncio.to_thread(attr, *args, **kwargs) + + return _offloaded + + class SessionStore: """ Manages session storage and retrieval. @@ -973,6 +1020,14 @@ class SessionStore: self._entries: Dict[str, SessionEntry] = {} self._loaded = False self._lock = threading.Lock() + # Serialize whole-index persistence without holding ``_lock`` across + # SQLite / fsync. Each writer snapshots the latest state only after + # acquiring this lock, preventing stale delayed writes. + self._save_lock = threading.Lock() + self._routing_generation = 0 + self._persisted_routing_generation = 0 + self._inflight_lock = threading.Lock() + self._inflight_sessions: Dict[str, _SessionFlight] = {} self._has_active_processes_fn = has_active_processes_fn # Whether to keep writing the legacy sessions.json mirror alongside # the primary gateway_routing table in state.db. Default True for @@ -1179,40 +1234,45 @@ class SessionStore: self._save() def _save(self) -> None: - """Persist the routing index (session key -> ID mapping). + """Persist the routing index while the caller holds ``_lock``.""" + data, generation = self._snapshot_routing_locked() + self._persist_routing_data(data, generation) - state.db's ``gateway_routing`` table is the primary store (#9006 - follow-up): the whole index is replaced atomically in one SQLite - transaction, mirroring the previous full-file JSON rewrite semantics. + def _snapshot_routing_locked(self) -> tuple[Dict[str, Any], int]: + """Capture immutable routing data and a monotonic generation.""" + self._routing_generation = getattr(self, "_routing_generation", 0) + 1 + return ( + {key: entry.to_dict() for key, entry in self._entries.items()}, + self._routing_generation, + ) - sessions.json is additionally written for backward compatibility - (external tooling, downgrade safety) unless the user disables it via - ``gateway.write_sessions_json: false`` in config.yaml. - """ - data = {key: entry.to_dict() for key, entry in self._entries.items()} - - # Primary: durable SQLite routing table. - db_saved = False - _db = getattr(self, "_db", None) - if _db: - replacer = getattr(_db, "replace_gateway_routing_entries", None) - if callable(replacer): - try: - replacer( - {k: json.dumps(v) for k, v in data.items()}, - scope=self._routing_scope(), - ) - db_saved = True - except Exception as exc: - logger.warning( - "gateway.session: state.db routing save failed: %s", exc - ) - - # Legacy mirror: sessions.json. Kept on by default for compat; when - # disabled we still fall back to it if the DB write failed, so the - # index is never lost entirely. - if getattr(self, "_write_sessions_json", True) or not db_saved: - self._save_sessions_json(data) + def _persist_routing_data(self, data: Dict[str, Any], generation: int) -> None: + """Serialize all whole-index writers through one durable write lock.""" + save_lock = getattr(self, "_save_lock", None) + if save_lock is None: + save_lock = threading.Lock() + self._save_lock = save_lock + with save_lock: + if generation <= getattr(self, "_persisted_routing_generation", 0): + return + db_saved = False + _db = getattr(self, "_db", None) + if _db: + replacer = getattr(_db, "replace_gateway_routing_entries", None) + if callable(replacer): + try: + replacer( + {k: json.dumps(v) for k, v in data.items()}, + scope=self._routing_scope(), + ) + db_saved = True + except Exception as exc: + logger.warning( + "gateway.session: state.db routing save failed: %s", exc + ) + if getattr(self, "_write_sessions_json", True) or not db_saved: + self._save_sessions_json(data) + self._persisted_routing_generation = generation def _save_sessions_json(self, data: Dict[str, Any]) -> None: """Write the legacy sessions.json mirror of the routing index.""" @@ -1254,6 +1314,11 @@ class SessionStore: logger.debug("Could not remove temp file %s: %s", tmp_path, e) raise + def _save_entries(self) -> None: + """Snapshot latest state under ``_lock`` and persist after releasing it.""" + with self._lock: + data, generation = self._snapshot_routing_locked() + self._persist_routing_data(data, generation) def _resolve_profile_for_key(self, source: Optional[SessionSource] = None) -> Optional[str]: """Return the profile namespace for session keys, or None when off. @@ -1395,6 +1460,51 @@ class SessionStore: now=now, ) + def _query_recoverable_session(self, *, session_key, source, now): + """DB-only half of _recover_session_from_db (no lock needed). + + Returns a SessionEntry or None. Caller assigns _entries[key] under lock. + """ + if not self._db: + return None + finder = getattr(self._db, "find_latest_gateway_session_for_peer", None) + if not callable(finder): + return None + try: + recovered = finder( + source=source.platform.value, + user_id=source.user_id, + session_key=session_key, + chat_id=source.chat_id, + chat_type=source.chat_type, + thread_id=source.thread_id, + ) + except Exception as exc: + logger.debug("Gateway session DB recovery failed for %s: %s", + session_key, exc) + return None + if not isinstance(recovered, dict): + return None + if not self._recovered_row_allowed_for_active_profile( + requested_session_key=session_key, + recovered=recovered, + ): + logger.warning( + "Gateway session DB recovery ignored %s for %s because " + "multiplex_profiles is disabled and the row belongs to a " + "different profile", + recovered.get("session_key"), + session_key, + ) + return None + try: + self._db.reopen_session(str(recovered["id"])) + except Exception as exc: + logger.debug("Gateway session DB reopen failed for %s: %s", + session_key, exc) + return self._create_entry_from_recovered_row( + row=recovered, session_key=session_key, source=source, now=now, + ) def _record_gateway_session_peer( self, session_id: str, @@ -1687,23 +1797,69 @@ class SessionStore: def get_or_create_session( self, source: SessionSource, - force_new: bool = False + force_new: bool = False, ) -> SessionEntry: - """ - Get an existing session or create a new one. + """Single-flight session lookup/create per routing key. - Evaluates reset policy to determine if the existing session is stale. - Creates a session record in SQLite when a new session starts. + Calls for different keys remain concurrent. Overlapping calls for the + same key share the owner's result, including concurrent ``force_new`` + deliveries, so only one routing transition and SQLite row is created. + """ + session_key = self._generate_session_key(source) + inflight_lock = getattr(self, "_inflight_lock", None) + if inflight_lock is None: + inflight_lock = threading.Lock() + self._inflight_lock = inflight_lock + self._inflight_sessions = {} + + with inflight_lock: + slot = self._inflight_sessions.get(session_key) + if slot is None: + slot = _SessionFlight() + self._inflight_sessions[session_key] = slot + owner = True + else: + owner = False + + if not owner: + slot.event.wait() + if slot.error is not None: + raise slot.error + assert slot.result is not None + return slot.result + + try: + result = self._get_or_create_session_impl(source, force_new=force_new) + slot.result = result + return result + except BaseException as exc: + slot.error = exc + raise + finally: + slot.event.set() + with inflight_lock: + self._inflight_sessions.pop(session_key, None) + + def _get_or_create_session_impl( + self, + source: SessionSource, + force_new: bool = False, + ) -> SessionEntry: + """Perform one session routing transition for the single-flight owner. + + All blocking I/O (SQLite SELECTs, routing-index rewrite + ``os.fsync``, + recovery DB queries) is performed *outside* ``self._lock``. The lock + protects only ``_entries`` / ``_loaded`` mutations. """ session_key = self._generate_session_key(source) now = _now() - # SQLite calls are made outside the lock to avoid holding it during I/O. - # All _entries / _loaded mutations are protected by self._lock. db_end_session_id = None db_create_kwargs = None existing_session_id = None + force_new_observed_entry = None + # ---- Phase 0: lock read -- existing session_id for compression tip ---- if not force_new: with self._lock: self._ensure_loaded_locked() @@ -1711,13 +1867,52 @@ class SessionStore: if entry is not None: existing_session_id = entry.session_id - # Look up the compression continuation outside the lock (DB I/O). + # Compression tip lookup outside the lock (DB I/O). canonical_existing_session_id = ( self._compression_tip_for_session_id(existing_session_id) if existing_session_id else None ) + # ---- Phase 1: lock read -- get entry snapshot for stale/reset checks ---- + _stale_session_id = None + _entry_for_checks = None + with self._lock: + self._ensure_loaded_locked() + if force_new: + force_new_observed_entry = self._entries.get(session_key) + if session_key in self._entries and not force_new: + _entry_for_checks = self._entries[session_key] + _stale_session_id = _entry_for_checks.session_id + + # ---- Phase 1b: no-lock I/O -- stale check + reset policy ---- + _is_stale = False + _reset_reason = None + if _entry_for_checks is not None and _stale_session_id is not None: + _is_stale = self._is_session_ended_in_db(_stale_session_id) + if _entry_for_checks.suspended: + _reset_reason = "suspended" + elif _entry_for_checks.resume_pending: + _reset_reason = self._should_reset(_entry_for_checks, source) + if not _reset_reason: + _fw = auto_continue_freshness_window() + _ref_time = ( + _entry_for_checks.last_resume_marked_at + or _entry_for_checks.updated_at + ) + if _fw > 0 and (now - _ref_time).total_seconds() > _fw: + _reset_reason = "resume_pending_expired" + else: + _reset_reason = self._should_reset(_entry_for_checks, source) + + # ---- Phase 2: lock write -- apply decisions to _entries ---- + _needs_save = False + _needs_recover = False + entry: Optional[SessionEntry] = None + was_auto_reset = False + auto_reset_reason = None + reset_had_activity = False + with self._lock: self._ensure_loaded_locked() @@ -1727,25 +1922,14 @@ class SessionStore: entry, existing_session_id, canonical_existing_session_id ) - # Self-heal stale routing: if this session_key still points at - # a session that has ALREADY been ended in state.db (end_reason - # set), the in-memory sessions.json entry is stale. Reusing it - # would route every incoming message into a closed session and - # silently drop it — with no log, no error, no response — until - # the gateway restarts and _prune_stale_sessions_locked() clears - # it (#54878 — the live-gateway variant of #52804/FM9, which - # only the startup prune previously caught). - # - # Drop the stale entry and fall through to the recovery path - # below. Leaving db_end_session_id None routes us into - # _recover_session_from_db, whose finder - # (hermes_state.find_latest_gateway_session_for_peer) selects - # rows WHERE `ended_at IS NULL OR end_reason = 'agent_close'` - # — so it REOPENS gateway-cleanup-ended ('agent_close') rows and - # resumes the SAME session_id (transcript preserved), but returns - # None for any other end_reason (e.g. /new), which then correctly - # starts a fresh session. - if self._is_session_ended_in_db(entry.session_id): + if _is_stale and entry.session_id == _stale_session_id: + # Stale routing self-heal (#54878): the in-memory entry + # points at a session that has ALREADY been ended in + # state.db. Drop it and fall through to recovery/create. + # Recovery finder reopens ``agent_close`` and mistaken + # ``ws_orphan_reap`` rows (preserving the transcript) but + # returns None for other end_reasons (e.g. /new), starting + # a fresh session. logger.warning( "gateway.session: routing key %r -> %s is ended in " "state.db but still live in sessions.json; dropping " @@ -1754,82 +1938,49 @@ class SessionStore: session_key, entry.session_id, ) self._entries.pop(session_key, None) - was_auto_reset = False - auto_reset_reason = None - reset_had_activity = False - # Fall through to the recovery/create path below; the - # stale entry is gone so we must NOT consult its - # suspended/resume/reset state. + entry = None + _needs_recover = True + elif entry.session_id != _stale_session_id: + # Another thread handled this entry during our lock-free + # window. Treat as healthy -- bump updated_at and save. + entry.updated_at = now + _needs_save = True else: - # Auto-reset sessions marked as suspended (e.g. after /stop - # broke a stuck loop — #7536). ``suspended`` is the hard - # forced-wipe signal and always wins over ``resume_pending``, - # so repeated interrupted restarts that escalate via the - # existing ``.restart_failure_counts`` stuck-loop counter - # still converge to a clean slate. - if entry.suspended: - reset_reason = "suspended" - elif entry.resume_pending: - # Restart-interrupted session: preserve the session_id - # and return the existing entry so the transcript reloads - # intact, but still honour normal daily/idle reset policy. - # - # Freshness gate (#46934): the idle/daily policy checks - # ``updated_at``, which is bumped to ``now`` on every - # message — so a zombie session that keeps receiving - # messages never trips it and would resume stale context - # forever. ``last_resume_marked_at`` is set once when - # resume was marked and never bumped per-message, so it - # correctly measures how long resume has been pending. - # If that exceeds the auto-continue freshness window, the - # recovery turn either never ran or failed — treat the - # session as a zombie and fall through to auto-reset. - reset_reason = self._should_reset(entry, source) - if not reset_reason: - _fw = auto_continue_freshness_window() - _ref_time = entry.last_resume_marked_at or entry.updated_at - if _fw > 0 and (now - _ref_time).total_seconds() > _fw: - reset_reason = "resume_pending_expired" - else: - entry.updated_at = now - self._save() - return entry - else: - reset_reason = self._should_reset(entry, source) - if not reset_reason: - entry.updated_at = now - self._save() - return entry - else: - # Session is being auto-reset. + # Stale check clean. Apply reset decision. + if _reset_reason: was_auto_reset = True - auto_reset_reason = reset_reason - # Track whether the expired session had any real - # conversation. total_tokens is never written (token - # counts migrated to agent-direct persistence) so it is - # always 0 — use last_prompt_tokens, updated every turn. + auto_reset_reason = _reset_reason reset_had_activity = entry.last_prompt_tokens > 0 db_end_session_id = entry.session_id + self._entries.pop(session_key, None) + entry = None + _needs_recover = True + else: + entry.updated_at = now + _needs_save = True else: - was_auto_reset = False - auto_reset_reason = None - reset_had_activity = False + if not force_new: + _needs_recover = True - if not force_new and not db_end_session_id: - recovered_entry = self._recover_session_from_db( - session_key=session_key, - source=source, - now=now, - ) - if recovered_entry is not None: - self._entries[session_key] = recovered_entry - self._save() - return recovered_entry + # ---- Phase 3: no-lock I/O -- recovery + create + save + DB ops ---- + if _needs_recover and db_end_session_id is None: + recovered = self._query_recoverable_session( + session_key=session_key, source=source, now=now, + ) + if recovered is not None: + with self._lock: + published = self._entries.get(session_key) + if published is None: + self._entries[session_key] = recovered + published = recovered + entry = published + _needs_save = True - # Create new session + if entry is None: + # Create a candidate outside the lock, then publish only if another + # worker has not already populated this routing key. session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" - - entry = SessionEntry( + candidate = SessionEntry( session_key=session_key, session_id=session_id, created_at=now, @@ -1842,20 +1993,35 @@ class SessionStore: auto_reset_reason=auto_reset_reason, reset_had_activity=reset_had_activity, ) + with self._lock: + current = self._entries.get(session_key) + may_publish = current is None or ( + force_new and current is force_new_observed_entry + ) + if may_publish: + self._entries[session_key] = candidate + published = candidate + else: + published = current + assert published is not None + entry = published + _needs_save = True + if entry is candidate: + db_create_kwargs = { + "session_id": session_id, + "source": source.platform.value, + "user_id": source.user_id, + "session_key": session_key, + "chat_id": source.chat_id, + "chat_type": source.chat_type, + "thread_id": source.thread_id, + "profile_name": source.profile, + } - self._entries[session_key] = entry - self._save() - db_create_kwargs = { - "session_id": session_id, - "source": source.platform.value, - "user_id": source.user_id, - "session_key": session_key, - "chat_id": source.chat_id, - "chat_type": source.chat_type, - "thread_id": source.thread_id, - } + if _needs_save: + self._save_entries() - # SQLite operations outside the lock + # SQLite operations outside the lock (unchanged). if self._db and db_end_session_id: try: self._db.end_session(db_end_session_id, "session_reset") @@ -2036,6 +2202,10 @@ class SessionStore: "has_active_processes_fn raised during prune for %s: %s", entry.session_key, exc, ) + # Fail safe: if we can't tell whether a background + # process is attached, keep the entry rather than + # risk orphaning live work. + continue if entry.updated_at < cutoff: removed_keys.append(key) for key in removed_keys: @@ -2126,6 +2296,7 @@ class SessionStore: "chat_id": old_entry.origin.chat_id if old_entry.origin else None, "chat_type": old_entry.origin.chat_type if old_entry.origin else None, "thread_id": old_entry.origin.thread_id if old_entry.origin else None, + "profile_name": old_entry.origin.profile if old_entry.origin else None, } if self._db and db_end_session_id: diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 3d7bed924c7b..ad61284f6a73 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -34,6 +34,7 @@ from agent.i18n import t from gateway.config import HomeChannel, Platform, PlatformConfig from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType from gateway.session import ( + AsyncSessionStore, SessionSource, build_session_key, is_shared_multi_user_session, @@ -86,6 +87,8 @@ def _model_switch_skew_guard() -> Optional[str]: class GatewaySlashCommandsMixin: """In-session slash-command handlers for GatewayRunner.""" + async_session_store: AsyncSessionStore + def _typed_command_prefix_for(self, platform) -> str: """Return the prefix users can always type to reach Hermes commands. @@ -198,7 +201,7 @@ class GatewaySlashCommandsMixin: pass # Reset the session - new_entry = self.session_store.reset_session(session_key) + new_entry = await self.async_session_store.reset_session(session_key) # Clear any session-scoped model/reasoning overrides so the next agent # picks up configured defaults instead of previous session switches. @@ -263,7 +266,7 @@ class GatewaySlashCommandsMixin: header = await asyncio.to_thread(self._telegram_topic_new_header, source) or t("gateway.reset.header_default") else: # No existing session, just create one - new_entry = self.session_store.get_or_create_session(source, force_new=True) + new_entry = await self.async_session_store.get_or_create_session(source, force_new=True) header = await asyncio.to_thread(self._telegram_topic_new_header, source) or t("gateway.reset.header_new") # Set session title if provided with /new <title> @@ -495,7 +498,7 @@ class GatewaySlashCommandsMixin: from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model source = event.source - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) connected_platforms = [p.value for p in self.adapters.keys()] @@ -1061,7 +1064,7 @@ class GatewaySlashCommandsMixin: """ from gateway.run import _AGENT_PENDING_SENTINEL, _INTERRUPT_REASON_STOP source = event.source - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) session_key = session_entry.session_key agent = self._running_agents.get(session_key) @@ -1109,6 +1112,26 @@ class GatewaySlashCommandsMixin: ) return EphemeralReply(t("gateway.stop.stopped")) + # No running agent anywhere for this scope. A platform status + # indicator can still be stuck — e.g. Slack's persistent + # assistant.threads.setStatus survives a gateway restart or a turn + # that died without a final send (#32295). Best-effort clear so + # /stop always dismisses a phantom "is thinking...". + adapter = getattr(self, "adapters", {}).get(source.platform) + if adapter and hasattr(adapter, "_stop_typing_with_metadata"): + try: + await adapter._stop_typing_with_metadata( + source.chat_id, + self._thread_metadata_for_source( + source, self._reply_anchor_for_event(event) + ), + ) + except Exception: + logger.debug( + "Failed to clear typing on /stop with no active agent", + exc_info=True, + ) + return t("gateway.stop.no_active") async def _handle_platform_command(self, event: MessageEvent) -> str: @@ -1598,7 +1621,7 @@ class GatewaySlashCommandsMixin: _sess_db = getattr(_self, "_session_db", None) if _sess_db is not None: try: - _sess_entry = _self.session_store.get_or_create_session( + _sess_entry = await _self.async_session_store.get_or_create_session( event.source ) await _sess_db.update_session_model( @@ -1629,7 +1652,7 @@ class GatewaySlashCommandsMixin: # store so the picked model survives a gateway restart # (api_key is never persisted). try: - _self.session_store.set_model_override( + await _self.async_session_store.set_model_override( _session_key, _self._session_model_overrides[_session_key], ) @@ -1840,7 +1863,7 @@ class GatewaySlashCommandsMixin: _sess_db = getattr(self, "_session_db", None) if _sess_db is not None: try: - _sess_entry = self.session_store.get_or_create_session(source) + _sess_entry = await self.async_session_store.get_or_create_session(source) # If this session was auto-reset, consume the flag so the # next regular message's cleanup does not wipe the model # override just stored below (Closes #48031). @@ -1878,8 +1901,9 @@ class GatewaySlashCommandsMixin: # api_key/api_mode are never persisted — they are re-resolved via # runtime provider resolution on rehydration. try: - self.session_store.set_model_override( - session_key, self._session_model_overrides[session_key] + await self.async_session_store.set_model_override( + session_key, + self._session_model_overrides[session_key], ) except Exception: logger.debug( @@ -2142,8 +2166,8 @@ class GatewaySlashCommandsMixin: async def _handle_retry_command(self, event: MessageEvent) -> str: """Handle /retry command - re-send the last user message.""" source = event.source - session_entry = self.session_store.get_or_create_session(source) - history = self.session_store.load_transcript(session_entry.session_id) + session_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(session_entry.session_id) # Find the last user message last_user_msg = None @@ -2159,10 +2183,10 @@ class GatewaySlashCommandsMixin: # Truncate history to before the last user message and persist truncated = history[:last_user_idx] - self.session_store.rewrite_transcript(session_entry.session_id, truncated) + await self.async_session_store.rewrite_transcript(session_entry.session_id, truncated) # Reset stored token count — transcript was truncated session_entry.last_prompt_tokens = 0 - + # Re-send by creating a fake text event with the old message retry_event = MessageEvent( text=last_user_msg, @@ -2189,7 +2213,7 @@ class GatewaySlashCommandsMixin: args = (event.get_command_args() or "").strip() lower = args.lower() - mgr, session_entry = self._get_goal_manager_for_event(event) + mgr, session_entry = await self._get_goal_manager_for_event(event) if mgr is None: return t("gateway.goal.unavailable") @@ -2323,7 +2347,7 @@ class GatewaySlashCommandsMixin: to invoke while the agent is running. """ args = (event.get_command_args() or "").strip() - mgr, _session_entry = self._get_goal_manager_for_event(event) + mgr, _session_entry = await self._get_goal_manager_for_event(event) if mgr is None: return t("gateway.goal.unavailable") if not mgr.has_goal(): @@ -2390,8 +2414,8 @@ class GatewaySlashCommandsMixin: if n < 1: n = 1 - session_entry = self.session_store.get_or_create_session(source) - result = self.session_store.rewind_session(session_entry.session_id, n) + session_entry = await self.async_session_store.get_or_create_session(source) + result = await self.async_session_store.rewind_session(session_entry.session_id, n) if result is None: return t("gateway.undo.nothing") @@ -2654,9 +2678,15 @@ class GatewaySlashCommandsMixin: _reasoning_source = await asyncio.to_thread(self._normalize_source_for_session_key, event.source) session_key = self._session_key_for_source(_reasoning_source) self._show_reasoning = self._load_show_reasoning() + # Use the session's effective model (session /model override wins over + # config default) so per-model reasoning_overrides display correctly. + _session_model = str( + ((getattr(self, "_session_model_overrides", {}) or {}).get(session_key) or {}).get("model") or "" + ) self._reasoning_config = self._resolve_session_reasoning_config( source=event.source, session_key=session_key, + model=_session_model, ) def _save_config_key(key_path: str, value): @@ -2729,7 +2759,7 @@ class GatewaySlashCommandsMixin: return t("gateway.reasoning.reset_done") if effort == "none": parsed = {"enabled": False} - elif effort in {"minimal", "low", "medium", "high", "xhigh"}: + elif effort in {"minimal", "low", "medium", "high", "xhigh", "max", "ultra"}: parsed = {"enabled": True, "effort": effort} else: return t( @@ -3090,8 +3120,8 @@ class GatewaySlashCommandsMixin: https://code.claude.com/docs/en/whats-new/2026-w20). """ source = event.source - session_entry = self.session_store.get_or_create_session(source) - history = self.session_store.load_transcript(session_entry.session_id) + session_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(session_entry.session_id) if not history or len(history) < 4: return t("gateway.compress.not_enough") @@ -3262,32 +3292,40 @@ class GatewaySlashCommandsMixin: # at it; in place the original transcript is untouched) and lets # the outer handler surface a "compress failed" banner instead. # - # The rewrite runs when EITHER rotation produced a new id OR - # in-place compaction succeeded. It is skipped in the THIRD - # case: _compress_context could NOT rotate AND was not in-place - # (e.g. legacy mode but _session_db unavailable / the DB split - # raised) — there session_id is unchanged for a FAILURE reason, - # and rewrite_transcript() would DELETE the original messages and - # replace them with only the compressed summary (permanent data - # loss #44794, #39704). In in-place mode the unchanged id is - # SUCCESS, so the rewrite is exactly right (and is the durable - # write when the throwaway /compress agent has no _session_db of - # its own). - if rotated or _in_place: - if not self.session_store.rewrite_transcript( + # Only rewrite the transcript when rotation produced a NEW + # session id. In-place compaction does NOT need a rewrite: + # archive_and_compact() has already soft-archived the previous + # active rows and inserted the compacted messages as the new + # active set inside _compress_context(). Calling + # rewrite_transcript() after in-place compaction would invoke + # replace_messages(active_only=False) which DELETEs ALL rows — + # including the archived turns that archive_and_compact() + # deliberately preserved (silent data loss, #61145). + # + # The third case: _compress_context could NOT rotate AND was + # not in-place (e.g. legacy mode but _session_db unavailable / + # the DB split raised) — there session_id is unchanged for a + # FAILURE reason, and rewrite_transcript() would DELETE the + # original messages and replace them with only the compressed + # summary (permanent data loss #44794, #39704). + if rotated: + if not await self.async_session_store.rewrite_transcript( new_session_id, compressed ): raise RuntimeError( f"failed to persist compressed transcript for " f"session {new_session_id}" ) - if rotated: - session_entry.session_id = new_session_id - self.session_store._save() - await asyncio.to_thread( - self._sync_telegram_topic_binding, - source, session_entry, reason="compress-command", - ) + session_entry.session_id = new_session_id + await self.async_session_store._save() + await asyncio.to_thread( + self._sync_telegram_topic_binding, + source, session_entry, reason="compress-command", + ) + elif _in_place: + # archive_and_compact() already persisted the compacted + # transcript inside _compress_context — nothing to do. + pass else: logger.warning( "Manual /compress: session rotation did not occur " @@ -3296,7 +3334,7 @@ class GatewaySlashCommandsMixin: "it (#44794)." ) # Reset stored token count — transcript changed, old value is stale - self.session_store.update_session( + await self.async_session_store.update_session( session_entry.session_key, last_prompt_tokens=0 ) new_tokens = estimate_request_tokens_rough( @@ -3445,7 +3483,7 @@ class GatewaySlashCommandsMixin: async def _handle_title_command(self, event: MessageEvent) -> str: """Handle /title command — set or show the current session's title.""" source = event.source - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) session_id = session_entry.session_id if not self._session_db: @@ -3628,7 +3666,7 @@ class GatewaySlashCommandsMixin: return t("gateway.resume.blocked_not_owner", name=name) # Check if already on that session - current_entry = self.session_store.get_or_create_session(source) + current_entry = await self.async_session_store.get_or_create_session(source) if current_entry.session_id == target_id: return t("gateway.resume.already_on", name=name) @@ -3636,7 +3674,7 @@ class GatewaySlashCommandsMixin: self._release_running_agent_state(session_key) # Switch the session entry to point at the old session - new_entry = self.session_store.switch_session(session_key, target_id) + new_entry = await self.async_session_store.switch_session(session_key, target_id) if not new_entry: return t("gateway.resume.switch_failed") self._clear_session_boundary_security_state(session_key) @@ -3673,7 +3711,7 @@ class GatewaySlashCommandsMixin: title = await self._session_db.get_session_title(target_id) or name # Count messages for context - history = self.session_store.load_transcript(target_id) + history = await self.async_session_store.load_transcript(target_id) msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0 msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else "" @@ -3724,7 +3762,7 @@ class GatewaySlashCommandsMixin: # `/sessions all` and enumerate other origins' session ids / titles / # previews / sources — the enumeration half of the /resume IDOR. cross_origin = include_all and self._resume_caller_is_admin(source) - current_entry = self.session_store.get_or_create_session(source) + current_entry = await self.async_session_store.get_or_create_session(source) rows = await asyncio.to_thread( query_session_listing, getattr(self._session_db, "_db", self._session_db), @@ -3773,8 +3811,8 @@ class GatewaySlashCommandsMixin: session_key = self._session_key_for_source(source) # Load the current session and its transcript - current_entry = self.session_store.get_or_create_session(source) - history = self.session_store.load_transcript(current_entry.session_id) + current_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(current_entry.session_id) if not history: return t("gateway.branch.no_conversation") @@ -3841,7 +3879,7 @@ class GatewaySlashCommandsMixin: pass # Switch the session store entry to the new session - new_entry = self.session_store.switch_session(session_key, new_session_id) + new_entry = await self.async_session_store.switch_session(session_key, new_session_id) if not new_entry: return t("gateway.branch.switch_failed") self._clear_session_boundary_security_state(session_key) @@ -3939,6 +3977,15 @@ class GatewaySlashCommandsMixin: source = event.source session_key = self._session_key_for_source(source) + # `/usage reset [--force]` — redeem one banked Codex rate-limit reset + # credit. Parsed before the display path so it never mixes with the + # stats rendering below. + raw_args = event.get_command_args().strip() + args = [a.lower() for a in raw_args.split()] if raw_args else [] + wants_reset = bool(args) and args[0] == "reset" + if args and not wants_reset: + return t("gateway.usage.unknown_subcommand", args=raw_args) + # Try running agent first (mid-turn), then cached agent (between turns) agent = self._running_agents.get(session_key) if not agent or agent is _AGENT_PENDING_SENTINEL: @@ -3959,13 +4006,28 @@ class GatewaySlashCommandsMixin: api_key = getattr(agent, "api_key", None) if agent and agent is not _AGENT_PENDING_SENTINEL else None if not provider and getattr(self, "_session_db", None) is not None: try: - _entry_for_billing = self.session_store.get_or_create_session(source) + _entry_for_billing = await self.async_session_store.get_or_create_session(source) persisted = await self._session_db.get_session(_entry_for_billing.session_id) or {} except Exception: persisted = {} provider = provider or persisted.get("billing_provider") base_url = base_url or persisted.get("billing_base_url") + if wants_reset: + normalized_provider = str(provider or "").strip().lower() + if normalized_provider != "openai-codex": + return t("gateway.usage.reset_wrong_provider") + force = "--force" in args[1:] + from agent.account_usage import redeem_codex_reset_credit + + result = await asyncio.to_thread( + redeem_codex_reset_credit, + base_url=base_url, + api_key=api_key, + force=force, + ) + return result.message + # Fetch account usage off the event loop so slow provider APIs don't # block the gateway. Failures are non-fatal -- account_lines stays []. account_lines: list[str] = [] @@ -4032,7 +4094,9 @@ class GatewaySlashCommandsMixin: # Same engine the desktop popover uses (PR #54907). The system # prompt / tools / skills / memory slices read off the live agent; # the conversation slice is estimated from the session transcript. - breakdown_lines = self._context_breakdown_lines(agent, source) + breakdown_lines = await asyncio.to_thread( + self._context_breakdown_lines, agent, source + ) if breakdown_lines: lines.append("") lines.extend(breakdown_lines) @@ -4047,8 +4111,8 @@ class GatewaySlashCommandsMixin: return "\n".join(lines) # No agent at all -- check session history for a rough count - session_entry = self.session_store.get_or_create_session(source) - history = self.session_store.load_transcript(session_entry.session_id) + session_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(session_entry.session_id) if history: from agent.model_metadata import estimate_messages_tokens_rough msgs = [m for m in history if m.get("role") in {"user", "assistant"} and m.get("content")] diff --git a/gateway/status.py b/gateway/status.py index d041193fdf09..7b8e9ff57583 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -18,6 +18,8 @@ import shlex import signal import subprocess import sys +import threading +import time from datetime import datetime, timezone from pathlib import Path from hermes_constants import get_hermes_home, _get_platform_default_hermes_home @@ -40,6 +42,9 @@ _gateway_lock_handle = None # past the JSON payload so runtime status / PID readers can still read the file # while another process holds the mutual-exclusion lock. _WINDOWS_LOCK_OFFSET = 1024 * 1024 +_GATEWAY_RUNNING_PID_CACHE_TTL_SECONDS = 1.0 +_gateway_running_pid_cache_lock = threading.Lock() +_gateway_running_pid_cache: dict[tuple[str, bool, bool], tuple[float, tuple[Any, ...], Optional[int]]] = {} def _get_process_hermes_home() -> Path: @@ -496,6 +501,33 @@ def _pid_from_record(record: Optional[dict[str, Any]]) -> Optional[int]: return None +def _clear_running_pid_cache() -> None: + with _gateway_running_pid_cache_lock: + _gateway_running_pid_cache.clear() + + +def _file_cache_signature(path: Path) -> tuple[bool, Optional[int], Optional[int]]: + try: + st = path.stat() + except OSError: + return (False, None, None) + return (True, st.st_mtime_ns, st.st_size) + + +def _running_pid_cache_signature( + pid_path: Path, + *, + include_runtime_status: bool, +) -> tuple[Any, ...]: + parts: list[Any] = [ + _file_cache_signature(pid_path), + _file_cache_signature(_get_gateway_lock_path(pid_path)), + ] + if include_runtime_status: + parts.append(_file_cache_signature(_get_runtime_status_path())) + return tuple(parts) + + def _cleanup_invalid_pid_path(pid_path: Path, *, cleanup_stale: bool) -> None: """Delete a stale gateway PID file (and its sibling lock metadata). @@ -508,6 +540,7 @@ def _cleanup_invalid_pid_path(pid_path: Path, *, cleanup_stale: bool) -> None: """ if not cleanup_stale: return + _clear_running_pid_cache() try: pid_path.unlink(missing_ok=True) except Exception: @@ -693,6 +726,7 @@ def acquire_gateway_runtime_lock() -> bool: return False _write_gateway_lock_record(handle) _gateway_lock_handle = handle + _clear_running_pid_cache() return True @@ -708,6 +742,7 @@ def release_gateway_runtime_lock() -> None: handle.close() except OSError: pass + _clear_running_pid_cache() def is_gateway_runtime_lock_active(lock_path: Optional[Path] = None) -> bool: @@ -750,6 +785,7 @@ def write_pid_file() -> None: try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(record) + _clear_running_pid_cache() except Exception: try: path.unlink(missing_ok=True) @@ -942,6 +978,7 @@ def remove_pid_file() -> None: # PID file belongs to a different process — leave it alone. return path.unlink(missing_ok=True) + _clear_running_pid_cache() except Exception: pass @@ -1447,6 +1484,54 @@ def get_running_pid( return None +def get_running_pid_cached( + pid_path: Optional[Path] = None, + *, + cleanup_stale: bool = True, + ttl_seconds: float = _GATEWAY_RUNNING_PID_CACHE_TTL_SECONDS, +) -> Optional[int]: + """Cached read-side wrapper for dashboard/status polling. + + ``get_running_pid()`` probes the runtime lock by briefly opening and locking + ``gateway.lock``. That is the right authoritative check for control paths, + but high-frequency read-only HTTP polling can call it hundreds of times per + minute. Cache for a short window and invalidate on PID/lock/runtime-status + file changes so status endpoints do not churn file descriptors while still + noticing gateway start/stop transitions quickly. + """ + if ttl_seconds <= 0: + return get_running_pid(pid_path, cleanup_stale=cleanup_stale) + + resolved_pid_path = pid_path or _get_pid_path() + include_runtime_status = pid_path is None + signature = _running_pid_cache_signature( + resolved_pid_path, + include_runtime_status=include_runtime_status, + ) + key = (str(resolved_pid_path), bool(cleanup_stale), include_runtime_status) + now = time.monotonic() + + with _gateway_running_pid_cache_lock: + cached = _gateway_running_pid_cache.get(key) + if cached is not None: + cached_at, cached_signature, cached_pid = cached + if now - cached_at <= ttl_seconds and cached_signature == signature: + return cached_pid + + pid = get_running_pid(pid_path, cleanup_stale=cleanup_stale) + refreshed_signature = _running_pid_cache_signature( + resolved_pid_path, + include_runtime_status=include_runtime_status, + ) + with _gateway_running_pid_cache_lock: + _gateway_running_pid_cache[key] = ( + time.monotonic(), + refreshed_signature, + pid, + ) + return pid + + def is_gateway_running( pid_path: Optional[Path] = None, *, diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index a08e169f2f99..fca8bf43847f 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -160,6 +160,10 @@ class GatewayStreamConsumer: # reply that was split across the platform's edit limit while streaming # doesn't leave stale fragments above the final message. self._preview_message_ids: "set[str]" = set() + # IDs from only the active text segment. A tool boundary preserves + # the run-wide set for fresh-final bookkeeping, but a failure recovery + # must never delete an earlier finalized preamble/commentary message. + self._segment_preview_message_ids: "set[str]" = set() self._already_sent = False self._edit_supported = True # Disabled when progressive edits are no longer usable self._last_edit_time = 0.0 @@ -173,6 +177,10 @@ class GatewayStreamConsumer: # Telegram overflow delivery. In that case the already-visible prefix # is intentional content, not a stale preview to delete. self._fallback_preserve_partial_messages = False + # Keep fallback recovery responsive. Telegram's adapter already bounds + # edit retries at five seconds; a final-delivery fallback must not hold + # the stream task through a longer flood cooldown before retrying. + self._max_fallback_flood_retry_seconds = 5.0 self._flood_strikes = 0 # Consecutive flood-control edit failures self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff self._final_response_sent = False @@ -341,6 +349,7 @@ class GatewayStreamConsumer: self._fallback_final_send = False self._fallback_prefix = "" self._fallback_preserve_partial_messages = False + self._segment_preview_message_ids = set() # #29346: a tool/segment boundary means what we delivered was an interim # preamble, not the final answer — clear the flags so a premature setter # can't fool the gateway. Safe: got_done returns before any reset, and @@ -982,6 +991,36 @@ class GatewayStreamConsumer: continuation = self._continuation_text(final_text) self._fallback_final_send = False if not continuation.strip(): + # Some platforms treat a successful streaming preview as durable + # delivery. Telegram clients can instead lose or retain only part + # of that preview after a failed final edit, so opt-in adapters + # commit the completed answer with a fresh final send. + if ( + final_text.strip() + and final_text == self._visible_prefix() + and getattr( + self.adapter, + "RESEND_FINAL_ON_EMPTY_STREAM_FALLBACK", + False, + ) is True + ): + delivery = await self._send_empty_fallback_final(final_text) + if delivery == "delivered": + return + self._already_sent = True + self._fallback_prefix = "" + self._fallback_preserve_partial_messages = False + if delivery == "ambiguous": + # A timeout may mean Telegram accepted the send but the + # client never received the response. Preserve duplicate + # suppression for that one uncertain outcome. + self._final_content_delivered = True + else: + # A confirmed failure leaves the gateway free to perform + # its normal final send. + self._final_response_sent = False + self._final_content_delivered = False + return # Nothing new to send — the visible partial already matches final text. # BUT: if final_text itself has meaningful content (e.g. a timeout # message after a long tool call), the prefix-based continuation @@ -1043,13 +1082,15 @@ class GatewayStreamConsumer: ) if result.success: break - if attempt == 0 and self._is_flood_error(result): + retry_delay = self._fallback_flood_retry_delay(result) + if attempt == 0 and retry_delay is not None: logger.debug( - "Flood control on fallback send, retrying in 3s" + "Flood control on fallback send, retrying in %.1fs", + retry_delay, ) - await asyncio.sleep(3.0) + await asyncio.sleep(retry_delay) else: - break # non-flood error or second attempt failed + break # non-flood error, long flood wait, or second failure if not result or not result.success: if sent_any_chunk: @@ -1112,6 +1153,103 @@ class GatewayStreamConsumer: self._fallback_prefix = "" self._fallback_preserve_partial_messages = False + async def _send_empty_fallback_final(self, final_text: str) -> str: + """Commit a completed answer after Telegram finalization fails. + + Returns ``delivered`` on confirmed success, ``failed`` when the + gateway can safely retry, and ``ambiguous`` when a timeout may have + reached the platform already. + """ + # Tool/segment boundaries intentionally preserve the run-wide preview + # IDs for normal fresh-final cleanup. This recovery replaces only the + # active final segment, so never delete an earlier finalized preamble. + stale_ids = set(self._segment_preview_message_ids) + if self._message_id and self._message_id != "__no_edit__": + stale_ids.add(str(self._message_id)) + + result = None + for attempt in range(2): + try: + result = await self.adapter.send( + chat_id=self.chat_id, + content=final_text, + metadata=self._metadata_for_send(final=True), + ) + except Exception as exc: + logger.debug("Empty fallback final send failed: %s", exc) + return ( + "ambiguous" + if self._send_failure_may_have_delivered(exc) + else "failed" + ) + + if getattr(result, "success", False): + break + retry_delay = self._fallback_flood_retry_delay(result) + if attempt == 0 and retry_delay is not None: + logger.debug( + "Flood control on empty fallback final send; retrying in %.1fs", + retry_delay, + ) + await asyncio.sleep(retry_delay) + continue + return ( + "ambiguous" + if self._send_failure_may_have_delivered(result) + else "failed" + ) + + new_message_id = getattr(result, "message_id", None) + delete_fn = getattr(self.adapter, "delete_message", None) + if delete_fn is not None: + for stale_id in stale_ids: + if not stale_id or stale_id == new_message_id: + continue + try: + await delete_fn(self.chat_id, stale_id) + except Exception as exc: + logger.debug( + "Empty fallback preview cleanup failed (%s): %s", + stale_id, + exc, + ) + + self._segment_preview_message_ids = set() + self._message_id = new_message_id or "__no_edit__" + self._already_sent = True + self._final_response_sent = True + self._final_content_delivered = True + self._last_sent_text = final_text + self._fallback_prefix = "" + self._fallback_preserve_partial_messages = False + self._notify_new_message() + return "delivered" + + @staticmethod + def _send_failure_may_have_delivered(result_or_exc: Any) -> bool: + """Return True for timeout failures where retrying may duplicate.""" + if getattr(result_or_exc, "retryable", None) is True: + return False + error = str(getattr(result_or_exc, "error", None) or result_or_exc).lower() + name = result_or_exc.__class__.__name__.lower() + return "timeout" in error or "timed out" in error or "timeout" in name + + def _fallback_flood_retry_delay(self, result: Any) -> float | None: + """Return a bounded retry delay for a fallback send, if safe to retry.""" + if not self._is_flood_error(result): + return None + try: + delay = float(getattr(result, "retry_after", None) or 3.0) + except (TypeError, ValueError): + delay = 3.0 + if delay > self._max_fallback_flood_retry_seconds: + logger.debug( + "Flood control requests %.1fs; leaving final delivery to the gateway", + delay, + ) + return None + return max(0.0, delay) + def _is_flood_error(self, result) -> bool: """Check if a SendResult failure is due to flood control / rate limiting.""" err = getattr(result, "error", "") or "" @@ -1246,11 +1384,12 @@ class GatewayStreamConsumer: if not prefix or not prefix.strip(): return try: - await self._edit_message( + result = await self._edit_message( message_id=self._message_id, content=prefix, ) - self._last_sent_text = prefix + if getattr(result, "success", False): + self._last_sent_text = prefix except Exception: pass # best-effort — don't let this block the fallback path @@ -1330,9 +1469,11 @@ class GatewayStreamConsumer: return base def _track_preview_id(self, message_id: Optional[str]) -> None: - """Record a real preview message id for fresh-final cleanup.""" + """Record a real preview message id for finalization cleanup.""" if message_id and message_id != "__no_edit__": - self._preview_message_ids.add(str(message_id)) + message_id = str(message_id) + self._preview_message_ids.add(message_id) + self._segment_preview_message_ids.add(message_id) def _track_preview_ids_from_result(self, result: Any) -> None: """Record every message id a send/edit result exposes: the primary id @@ -1665,6 +1806,7 @@ class GatewayStreamConsumer: self._flood_strikes = 0 return True else: + immediate_final_fallback = False if ( finalize and is_turn_final @@ -1726,13 +1868,31 @@ class GatewayStreamConsumer: self._MAX_FLOOD_STRIKES, self._current_edit_interval, ) - if self._flood_strikes < self._MAX_FLOOD_STRIKES: + immediate_final_fallback = ( + finalize + and is_turn_final + and getattr( + self.adapter, + "FALLBACK_ON_FINAL_EDIT_FLOOD", + False, + ) is True + ) + if ( + self._flood_strikes < self._MAX_FLOOD_STRIKES + and not immediate_final_fallback + ): # Don't disable edits yet — just slow down. # Update _last_edit_time so the next edit # respects the new interval. self._last_edit_time = time.monotonic() return False + if immediate_final_fallback: + logger.debug( + "Turn-final edit hit flood control; " + "entering fallback immediately" + ) + # Non-flood error OR flood strikes exhausted: enter # fallback mode — send only the missing tail once the # final response is available. @@ -1745,8 +1905,12 @@ class GatewayStreamConsumer: self._edit_supported = False self._already_sent = True # Best-effort: strip the cursor from the last visible - # message so the user doesn't see a stuck ▉. - await self._try_strip_cursor() + # message so the user doesn't see a stuck ▉. A + # turn-final Telegram flood skips this cosmetic edit: + # another edit would consume the same flood budget and + # delay the fallback send that carries the answer. + if not immediate_final_fallback: + await self._try_strip_cursor() return False else: # Editing not supported — skip intermediate updates. diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index b2be73871b25..a4027221de4a 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -160,6 +160,12 @@ def build_top_level_parser(): default=None, help="Resume a previous session by ID or title", ) + parser.add_argument( + "--no-restore-cwd", + action="store_true", + default=False, + help="Don't cd into a resumed session's recorded working directory.", + ) parser.add_argument( "--continue", "-c", @@ -271,12 +277,27 @@ def build_top_level_parser(): chat_parser.add_argument( "--image", help="Optional local image path to attach to a single query" ) + # `default=argparse.SUPPRESS` on flags that are ALSO declared on the + # top-level parser: when the user writes `hermes -m foo chat`, argparse + # first sets `args.model = "foo"` from the top-level parser, then + # dispatches to the chat subparser. Without SUPPRESS the chat subparser's + # own default (`None`) would silently clobber the top-level value because + # the subparser shares the same namespace and `dest`. SUPPRESS keeps the + # subparser action a no-op unless the user actually passes the flag after + # the subcommand. Matches the pattern already used for `-s/--skills` and + # the relaunch-inherited flags `-r/--resume`, `-c/--continue`, + # `-w/--worktree`, `--yolo`, etc. (see tests/hermes_cli/ + # test_argparse_flag_propagation.py). _inherited_flag( chat_parser, - "-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)", + "-m", "--model", + default=argparse.SUPPRESS, + help="Model to use (e.g., anthropic/claude-sonnet-4)", ) chat_parser.add_argument( - "-t", "--toolsets", help="Comma-separated toolsets to enable" + "-t", "--toolsets", + default=argparse.SUPPRESS, + help="Comma-separated toolsets to enable", ) _inherited_flag( chat_parser, @@ -293,7 +314,7 @@ def build_top_level_parser(): # are also valid values, and runtime resolution (resolve_runtime_provider) # handles validation/error reporting consistently with the top-level # `--provider` flag. - default=None, + default=argparse.SUPPRESS, help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.", ) chat_parser.add_argument( @@ -316,6 +337,12 @@ def build_top_level_parser(): default=argparse.SUPPRESS, help="Resume a previous session by ID (shown on exit)", ) + chat_parser.add_argument( + "--no-restore-cwd", + action="store_true", + default=argparse.SUPPRESS, + help="Don't cd into a resumed session's recorded working directory.", + ) chat_parser.add_argument( "--continue", "-c", @@ -401,14 +428,14 @@ def build_top_level_parser(): chat_parser, "--tui", action="store_true", - default=False, + default=argparse.SUPPRESS, help="Launch the modern TUI instead of the classic REPL", ) _inherited_flag( chat_parser, "--cli", action="store_true", - default=False, + default=argparse.SUPPRESS, help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)", ) _inherited_flag( @@ -416,7 +443,7 @@ def build_top_level_parser(): "--dev", dest="tui_dev", action="store_true", - default=False, + default=argparse.SUPPRESS, help="With --tui: run TypeScript sources via tsx (skip dist build)", ) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 5cf6d50186a3..8804bcbeca15 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1298,6 +1298,27 @@ def get_auth_provider_display_name(provider_id: str) -> str: return SERVICE_PROVIDER_NAMES.get(normalized, provider_id) +def is_runtime_provider_routable(provider_id: str) -> bool: + """Return whether runtime resolution recognizes a provider identity. + + This is a capability check, not a credential check. It follows the same + alias/plugin-aware normalization as ``resolve_provider`` while preserving + special runtime identities that intentionally live outside the registry. + """ + normalized = (provider_id or "").strip().lower() + if not normalized: + return False + if normalized in {"auto", "openrouter", "custom", "moa"}: + return True + if normalized.startswith("custom:"): + return True + try: + resolve_provider(normalized) + except AuthError: + return False + return True + + def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]: """Return the persisted credential pool, or one provider slice. @@ -5561,52 +5582,72 @@ def resolve_nous_runtime_credentials( persisted_state = dict(state) state_persisted = False - portal_base_url = ( - _optional_base_url(state.get("portal_base_url")) - or os.getenv("HERMES_PORTAL_BASE_URL") - or os.getenv("NOUS_PORTAL_BASE_URL") - or DEFAULT_NOUS_PORTAL_URL - ).rstrip("/") + def _resolve_effective_routing_metadata() -> tuple[str, str, str, str]: + """Resolve every routing value that shared OAuth state can replace.""" + portal_url = ( + _optional_base_url(state.get("portal_base_url")) + or os.getenv("HERMES_PORTAL_BASE_URL") + or os.getenv("NOUS_PORTAL_BASE_URL") + or DEFAULT_NOUS_PORTAL_URL + ).rstrip("/") - # A persisted/stale portal_base_url is where the refresh token gets - # POSTed on refresh — reject any host outside the allowlist so a - # poisoned value can't exfiltrate the bearer, healing to the default. - # The trusted operator/deployment env override (HERMES_PORTAL_BASE_URL / - # NOUS_PORTAL_BASE_URL) bypasses this gate entirely — mirrors - # NOUS_INFERENCE_BASE_URL's treatment below; the allowlist exists to - # reject an untrusted NETWORK-provided value, not one the operator - # explicitly configured. - env_portal_override = _nous_portal_env_override() - if env_portal_override: - portal_base_url = env_portal_override.rstrip("/") - else: - parsed_portal_url = urlparse(portal_base_url) - if parsed_portal_url.hostname and parsed_portal_url.hostname not in _NOUS_PORTAL_ALLOWED_HOSTS: - logger.warning( - "auth: ignoring invalid portal_base_url %r (host %r not in allowlist), using default", - portal_base_url, parsed_portal_url.hostname, + # A persisted/stale portal_base_url is where the refresh token gets + # POSTed on refresh — reject any host outside the allowlist so a + # poisoned value can't exfiltrate the bearer, healing to the default. + # Trusted operator env overrides bypass this network-value gate. + env_portal_override = _nous_portal_env_override() + if env_portal_override: + portal_url = env_portal_override.rstrip("/") + else: + parsed_portal_url = urlparse(portal_url) + portal_host = parsed_portal_url.hostname + loopback_http = ( + parsed_portal_url.scheme == "http" + and portal_host in {"localhost", "127.0.0.1"} ) - portal_base_url = DEFAULT_NOUS_PORTAL_URL + trusted_scheme = ( + parsed_portal_url.scheme == "https" or loopback_http + ) + if ( + not portal_host + or portal_host not in _NOUS_PORTAL_ALLOWED_HOSTS + or not trusted_scheme + ): + logger.warning( + "auth: ignoring invalid portal_base_url %r " + "(host %r or scheme not allowed), using default", + portal_url, + portal_host, + ) + portal_url = DEFAULT_NOUS_PORTAL_URL - # Persisted value: validated network-provenance only. The stored - # inference_base_url is re-validated on read so a poisoned/stale - # staging host (persisted before the allowlist existed) heals to the - # production default on the no-refresh read path — this is what gets - # written back to auth.json. The env override is deliberately NOT - # folded in here: it must never be persisted (it's a runtime overlay). - stored_inference_base_url = ( - _validate_nous_inference_url_from_network( - _optional_base_url(state.get("inference_base_url")) + # Re-validate persisted network-provenance on every shared merge. + # The env override is runtime-only and must never be persisted. + stored_inference_url = ( + _validate_nous_inference_url_from_network( + _optional_base_url(state.get("inference_base_url")) + ) + or DEFAULT_NOUS_INFERENCE_URL ) - or DEFAULT_NOUS_INFERENCE_URL - ) - # Effective value used to build the client / returned to callers: - # the NOUS_INFERENCE_BASE_URL env override wins (documented dev/staging - # escape hatch), else the validated stored value. - inference_base_url = ( - _nous_inference_env_override() or stored_inference_base_url - ) - client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID) + effective_inference_url = ( + _nous_inference_env_override() or stored_inference_url + ) + effective_client_id = str( + state.get("client_id") or DEFAULT_NOUS_CLIENT_ID + ) + return ( + portal_url, + stored_inference_url, + effective_inference_url, + effective_client_id, + ) + + ( + portal_base_url, + stored_inference_base_url, + inference_base_url, + client_id, + ) = _resolve_effective_routing_metadata() def _persist_state(reason: str) -> None: nonlocal persisted_state, state_persisted @@ -5659,6 +5700,21 @@ def resolve_nous_runtime_credentials( access_token = state.get("access_token") refresh_token = state.get("refresh_token") + if not isinstance(access_token, str) or not access_token: + with _nous_shared_store_lock( + timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS) + ): + if _merge_shared_nous_oauth_state(state): + access_token = state.get("access_token") + refresh_token = state.get("refresh_token") + ( + portal_base_url, + stored_inference_base_url, + inference_base_url, + client_id, + ) = _resolve_effective_routing_metadata() + _persist_state("runtime_shared_merge_missing_access_token") + if not isinstance(access_token, str) or not access_token: raise AuthError("No access token found for Nous Portal login.", provider="nous", relogin_required=True) @@ -5673,6 +5729,12 @@ def resolve_nous_runtime_credentials( if _merge_shared_nous_oauth_state(state): access_token = state.get("access_token") refresh_token = state.get("refresh_token") + ( + portal_base_url, + stored_inference_base_url, + inference_base_url, + client_id, + ) = _resolve_effective_routing_metadata() invoke_jwt_status = _nous_invoke_jwt_status( access_token, scope=state.get("scope"), @@ -5737,6 +5799,11 @@ def resolve_nous_runtime_credentials( inference_base_url = ( _nous_inference_env_override() or stored_inference_base_url ) + # Persist network-derived routing with rotated tokens so + # a later JWT validation failure cannot leave the profile + # and shared stores on stale metadata. Never persist the + # operator-only env overlay. + state["inference_base_url"] = stored_inference_base_url state["obtained_at"] = now.isoformat() state["expires_in"] = access_ttl state["expires_at"] = datetime.fromtimestamp( diff --git a/hermes_cli/azure_detect.py b/hermes_cli/azure_detect.py index 1420d9334d6c..7638ed6aca97 100644 --- a/hermes_cli/azure_detect.py +++ b/hermes_cli/azure_detect.py @@ -46,6 +46,8 @@ from urllib import request as urllib_request from urllib.error import HTTPError, URLError from urllib.parse import urlparse +from hermes_cli.urllib_security import open_credentialed_url + logger = logging.getLogger(__name__) @@ -158,7 +160,7 @@ def _http_get_json(url: str, _apply_auth_headers(req, token, mode) req.add_header("User-Agent", "hermes-agent/azure-detect") try: - with urllib_request.urlopen(req, timeout=timeout) as resp: + with open_credentialed_url(req, timeout=timeout) as resp: body = resp.read() try: return resp.status, json.loads(body.decode("utf-8", errors="replace")) @@ -269,7 +271,7 @@ def _probe_anthropic_messages(base_url: str, req.add_header("content-type", "application/json") req.add_header("User-Agent", "hermes-agent/azure-detect") try: - with urllib_request.urlopen(req, timeout=6.0) as resp: + with open_credentialed_url(req, timeout=6.0) as resp: # Should never 200 — "probe" isn't a real deployment. But # if it does, the endpoint definitely speaks Anthropic. return resp.status < 500 diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index a71d88356985..d3c967405ad1 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -390,6 +390,7 @@ class CLIAgentSetupMixin: tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None, notice_callback=self._on_notice, notice_clear_callback=self._on_notice_clear, + reaction_callback=self._on_reaction, ) # Store reference for atexit memory provider shutdown. # NOTE: this MUST write to the ``cli`` module's global, not a diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index b16d2166e2c6..c8bf1e67136c 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2471,7 +2471,7 @@ class CLICommandsMixin: Usage: /reasoning Show current effort level and display state - /reasoning <level> Set reasoning effort (none, minimal, low, medium, high, xhigh) + /reasoning <level> Set effort (none, minimal, low, medium, high, xhigh, max, ultra) /reasoning show|on Show model thinking/reasoning in output /reasoning hide|off Hide model thinking/reasoning from output /reasoning full Show complete thinking (no 10-line clamp) @@ -2493,7 +2493,7 @@ class CLICommandsMixin: full_state = "full" if getattr(self, "reasoning_full", False) else "clamped to 10 lines" _cprint(f" {_ACCENT}Reasoning effort: {level}{_RST}") _cprint(f" {_ACCENT}Reasoning display: {display_state} ({full_state}){_RST}") - _cprint(f" {_DIM}Usage: /reasoning <none|minimal|low|medium|high|xhigh|show|hide|full|clamp>{_RST}") + _cprint(f" {_DIM}Usage: /reasoning <none|minimal|low|medium|high|xhigh|max|ultra|show|hide|full|clamp>{_RST}") return arg = parts[1].strip().lower() @@ -2534,7 +2534,7 @@ class CLICommandsMixin: parsed = _parse_reasoning_config(arg) if parsed is None: _cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}") - _cprint(f" {_DIM}Valid levels: none, minimal, low, medium, high, xhigh{_RST}") + _cprint(f" {_DIM}Valid levels: none, minimal, low, medium, high, xhigh, max, ultra{_RST}") _cprint(f" {_DIM}Display: show, hide{_RST}") return diff --git a/hermes_cli/codex_models.py b/hermes_cli/codex_models.py index 768e68bee381..a56cddf73a20 100644 --- a/hermes_cli/codex_models.py +++ b/hermes_cli/codex_models.py @@ -12,6 +12,14 @@ import os logger = logging.getLogger(__name__) DEFAULT_CODEX_MODELS: List[str] = [ + # GPT-5.6 series (Sol/Terra/Luna + -pro high-effort modes) — GA 2026-07-09 + # (previewed 2026-06-26). + "gpt-5.6-sol", + "gpt-5.6-sol-pro", + "gpt-5.6-terra", + "gpt-5.6-terra-pro", + "gpt-5.6-luna", + "gpt-5.6-luna-pro", "gpt-5.5", "gpt-5.4-mini", "gpt-5.4", @@ -44,6 +52,12 @@ DEFAULT_CODEX_MODELS: List[str] = [ ] _FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [ + ("gpt-5.6-sol", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-sol-pro", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-terra", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-terra-pro", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-luna", ("gpt-5.5", "gpt-5.4")), + ("gpt-5.6-luna-pro", ("gpt-5.5", "gpt-5.4")), ("gpt-5.5", ("gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex")), ("gpt-5.4-mini", ("gpt-5.3-codex",)), ("gpt-5.4", ("gpt-5.3-codex",)), diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 3e2d03dc3580..10f8fbf046b8 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -154,7 +154,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ "Configuration"), CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", args_hint="[level|show|hide|full|clamp]", - subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "show", "hide", "on", "off", "full", "clamp")), + subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp")), CommandDef("fast", "Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode (Normal/Fast)", "Configuration", args_hint="[normal|fast|status]", subcommands=("normal", "fast", "status", "on", "off")), @@ -228,7 +228,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("help", "Show available commands", "Info"), CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session", gateway_only=True), - CommandDef("usage", "Show token usage and rate limits for the current session", "Info"), + CommandDef("usage", "Show token usage and rate limits; `reset` redeems a banked Codex limit reset", "Info", + args_hint="[reset [--force]]"), CommandDef("credits", "Show Nous credit balance and top up", "Info"), CommandDef("billing", "Manage Nous terminal billing — buy credits, auto-reload, limits", "Info", cli_only=True), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e24cc220f4fa..45cee2071232 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -93,7 +93,9 @@ def _backup_corrupt_config(config_path: Path) -> Optional[Path]: return None -def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: +def _warn_config_parse_failure( + config_path: Path, exc: Exception, *, fallback: str = "defaults" +) -> None: """Surface a config.yaml parse failure to user, log, and stderr. A YAML parse error in ``~/.hermes/config.yaml`` causes ``load_config()`` @@ -110,6 +112,11 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: timestamped ``.bak`` (best-effort) so the user's recoverable content survives any later rewrite of ``config.yaml`` by the setup wizard or ``hermes config set``. + + ``fallback`` selects the message wording: ``"defaults"`` (fresh process, + nothing else to serve) or ``"last-known-good"`` (in-process retention of + the previously loaded config — see the codex#31188 port in + ``_load_config_impl``). """ try: st = config_path.stat() @@ -122,12 +129,19 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: backup_path = _backup_corrupt_config(config_path) - msg = ( - f"Failed to parse {config_path}: {exc}. " - f"Falling back to default config — every user override " - f"(auxiliary providers, fallback chain, model settings) is being IGNORED. " - f"Fix the YAML and restart." - ) + if fallback == "last-known-good": + msg = ( + f"Failed to parse {config_path}: {exc}. " + f"Keeping the previously loaded config for this process — " + f"edits to config.yaml are being IGNORED until the YAML is fixed." + ) + else: + msg = ( + f"Failed to parse {config_path}: {exc}. " + f"Falling back to default config — every user override " + f"(auxiliary providers, fallback chain, model settings) is being IGNORED. " + f"Fix the YAML and restart." + ) if backup_path is not None: msg += f" A copy of the corrupted file was saved to {backup_path}." logger.warning(msg) @@ -1144,8 +1158,15 @@ DEFAULT_CONFIG = { # only controls how inbound user images are presented. "image_input_mode": "auto", "disabled_toolsets": [], + + # Per-model reasoning effort overrides (spelling-tolerant). + # Dict mapping model names (any reasonable spelling) to effort levels. + # Takes precedence over agent.reasoning_effort when the current model + # matches a key in this dict. + # Edit directly in config.yaml (no CLI support due to dots in keys). + "reasoning_overrides": {}, }, - + "terminal": { "backend": "local", "modal_mode": "auto", @@ -1397,7 +1418,11 @@ DEFAULT_CONFIG = { "compression": { "enabled": True, - "threshold": 0.50, # compress when context usage exceeds this ratio + "threshold": 0.50, # compress when context usage exceeds this ratio. + # Models with context windows below 512K are + # floored at 0.75 (raise-only) so compaction + # doesn't fire with half the window still free; + # set this above 0.75 to override the floor. "target_ratio": 0.20, # fraction of threshold to preserve as recent tail "protect_last_n": 20, # minimum recent messages to keep uncompressed "hygiene_hard_message_limit": 5000, # gateway session-hygiene force-compress threshold by message count @@ -1419,17 +1444,18 @@ DEFAULT_CONFIG = { # True if you'd rather pause than silently lose # context turns when your aux model is flaky. "codex_gpt55_autoraise": True, # Historical key name kept for compatibility. - # When True, gpt-5.4 / gpt-5.5 on the ChatGPT Codex - # OAuth route raise their compaction trigger to 85% - # (vs the global `threshold` above). Codex hard-caps - # both families at a 272K window, so the default 50% - # would compact at ~136K and waste half the usable - # context. Set to False to opt back down to the global - # threshold (e.g. 0.50) for those Codex sessions. - # Only this exact route is affected — gpt-5.4 / 5.5 - # on OpenAI's direct API, OpenRouter, and Copilot keep - # the global threshold regardless. - "codex_gpt55_autoraise_notice": True, # Display the one-time Codex gpt-5.4/5.5 + # When True, gpt-5.4 / gpt-5.5 / gpt-5.6 on the + # ChatGPT Codex OAuth route raise their compaction + # trigger to 85% (vs the global `threshold` above). + # Codex hard-caps these families at a 272K window, so + # the default 50% would compact at ~136K and waste half + # the usable context. Set to False to opt back down to + # the global threshold (e.g. 0.50) for those Codex + # sessions. Only this exact route is affected — + # gpt-5.4 / 5.5 / 5.6 on OpenAI's direct API, + # OpenRouter, and Copilot keep the global threshold + # regardless. + "codex_gpt55_autoraise_notice": True, # Display the one-time Codex gpt-5.4/5.5/5.6 # autoraise banner. Set False to keep the # 85% threshold autoraise but suppress the # user-facing notice in CLI/gateway output. @@ -1562,6 +1588,7 @@ DEFAULT_CONFIG = { "api_key": "", # API key for base_url (falls back to OPENAI_API_KEY) "timeout": 120, # seconds — LLM API call timeout; vision payloads need generous timeout "extra_body": {}, # OpenAI-compatible provider-specific request fields + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) "download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections }, "web_extract": { @@ -1571,6 +1598,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, "compression": { "provider": "auto", @@ -1579,6 +1607,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 120, # seconds — compression summarises large contexts; increase for local models "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Note: session_search no longer uses an auxiliary LLM (PR #27590 — # single-shape tool returns DB content directly). The old @@ -1591,6 +1620,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 30, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, "approval": { "provider": "auto", @@ -1599,6 +1629,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 30, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, "mcp": { "provider": "auto", @@ -1607,6 +1638,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 30, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, "title_generation": { "provider": "auto", @@ -1615,6 +1647,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 30, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) "language": "", }, "tts_audio_tags": { @@ -1624,6 +1657,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 30, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Triage specifier — flesh out a rough one-liner in the Kanban # Triage column into a concrete spec, then promote it to ``todo``. @@ -1637,6 +1671,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 120, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Kanban decomposer — decomposes a triage task into a graph of # child tasks routed to specialist profiles by description. @@ -1650,6 +1685,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 180, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Profile describer — auto-generates a 1-2 sentence description # of what a profile is good at. Invoked by @@ -1662,6 +1698,19 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 60, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) + }, + # Goal judge — evaluates whether a /goal run's latest response + # satisfies the goal/contract, and drafts goal contracts. Short + # structured-JSON calls; a fast cheap model is fine. + "goal_judge": { + "provider": "auto", + "model": "", + "base_url": "", + "api_key": "", + "timeout": 60, + "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Curator — skill-usage review fork. Timeout is generous because the # review pass can take several minutes on reasoning models (umbrella @@ -1675,6 +1724,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 600, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Monitor — urgency/importance classifier used by the important-mail # monitor catalog automation (cron/scripts/classify_items.py). Scores @@ -1689,6 +1739,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 60, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, # Background review — the post-turn self-improvement fork that decides # whether to save a memory / patch a skill. "auto" (default) = run on @@ -1708,6 +1759,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 120, "extra_body": {}, + "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) }, "moa_reference": { "provider": "auto", @@ -1716,6 +1768,10 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 900, "extra_body": {}, + # NOTE: no reasoning_effort here by design — MoA reasoning depth is + # configured PER SLOT in the MoA preset (moa.presets.<name>. + # reference_models[].reasoning_effort / aggregator.reasoning_effort), + # not at the auxiliary-task level. }, "moa_aggregator": { "provider": "auto", @@ -1724,6 +1780,7 @@ DEFAULT_CONFIG = { "api_key": "", "timeout": 900, "extra_body": {}, + # NOTE: no reasoning_effort here by design — see moa_reference above. }, }, @@ -2045,7 +2102,9 @@ DEFAULT_CONFIG = { # limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware, # Gemini 32000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000). "tts": { - "provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local) + # Set explicitly to pin a backend: + # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "deepinfra" | "neutts" (local) | "kittentts" (local) | "piper" (local) + "provider": "edge", "edge": { "voice": "en-US-AriaNeural", # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural @@ -2101,15 +2160,20 @@ DEFAULT_CONFIG = { # "volume": 1.0, # "normalize_audio": True, }, + "deepinfra": { + "model": "", # empty = first tts-tagged model from the live catalog + "voice": "default", + # "base_url": "", # override DEEPINFRA_BASE_URL for TTS only + }, }, - + "stt": { "enabled": True, # When true, gateway voice messages are transcribed for the agent and # the raw transcript is also echoed back to the user as a 🎙️ message. # Set false to keep STT for the agent while suppressing that user-facing echo. "echo_transcripts": True, - "provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | "elevenlabs" (Scribe) + "provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | "elevenlabs" (Scribe) | "deepinfra" "local": { "model": "base", # tiny, base, small, medium, large-v3 "language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force @@ -2126,6 +2190,10 @@ DEFAULT_CONFIG = { "tag_audio_events": False, "diarize": False, }, + "deepinfra": { + "model": "", # empty = first stt-tagged model from the live catalog + # "base_url": "", # override DEEPINFRA_BASE_URL for STT only + }, }, "voice": { @@ -2221,8 +2289,8 @@ DEFAULT_CONFIG = { # (API, tools, iteration budget), never a delegation # stopwatch. Set a positive number of seconds # (floor 30s) to enforce a hard cap. - "reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium", - # "low", "minimal", "none" (empty = inherit parent's level) + "reasoning_effort": "", # subagent effort: "ultra", "max", "xhigh", "high", + # "medium", "low", "minimal", "none" (empty = inherit) "max_concurrent_children": 3, # unified concurrency cap: max parallel children per batch # AND max concurrent background (background=true) # delegation units. New async dispatches beyond the cap @@ -2504,15 +2572,15 @@ DEFAULT_CONFIG = { }, # Approval mode for dangerous commands: - # manual — always prompt the user (default) - # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk + # manual — always prompt the user + # smart — use auxiliary LLM to auto-approve low-risk commands (default) # off — skip all approval prompts (equivalent to --yolo) # # cron_mode — what to do when a cron job hits a dangerous command: # deny — block the command and let the agent find another way (default, safe) # approve — auto-approve all dangerous commands in cron jobs "approvals": { - "mode": "manual", + "mode": "smart", "timeout": 60, "cron_mode": "deny", # User-defined deny rules: fnmatch globs matched against terminal @@ -2675,6 +2743,12 @@ DEFAULT_CONFIG = { # recent .md files and prunes older ones. 0 or negative disables # pruning (for operators who manage cleanup externally). Default 50. "output_retention": 50, + # Timeout (seconds) for SessionDB() init inside cron jobs. + # SessionDB opens/migrates state.db synchronously and has no timeout + # of its own against a wedged sqlite3.connect. An unbounded hang here + # wedges the job's dispatch guard forever. Also overridable via + # HERMES_CRON_SESSION_DB_TIMEOUT env var. 0 = unlimited (skip the bound). + "session_db_timeout_seconds": 10, }, # Kanban multi-agent coordination — controls the dispatcher loop that @@ -3516,6 +3590,14 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, + "FIREWORKS_API_KEY": { + "description": "Fireworks AI API key", + "prompt": "Fireworks AI API key", + "url": "https://app.fireworks.ai/settings/users/api-keys", + "password": True, + "category": "provider", + "advanced": True, + }, "MINIMAX_API_KEY": { "description": "MiniMax API key (international)", "prompt": "MiniMax API key", @@ -3663,6 +3745,21 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, + "UPSTAGE_API_KEY": { + "description": "Upstage API key for Solar LLM models", + "prompt": "Upstage API Key", + "url": "https://console.upstage.ai/api-keys", + "password": True, + "category": "provider", + }, + "UPSTAGE_BASE_URL": { + "description": "Upstage base URL override (default: https://api.upstage.ai/v1)", + "prompt": "Upstage base URL (leave empty for default)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, "AWS_REGION": { "description": "AWS region for Bedrock API calls (e.g. us-east-1, eu-central-1)", "prompt": "AWS Region", @@ -3694,7 +3791,6 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, - # ── Tool API keys ── "EXA_API_KEY": { "description": "Exa API key for AI-native web search and contents", @@ -5440,10 +5536,13 @@ def _persist_migration(config: Dict[str, Any]) -> None: Every migration step MUST route its write through this helper instead of calling ``save_config`` directly. It is a thin wrapper over - ``save_config(config)`` (default-stripping ON); centralising the call makes - the invariant impossible to regress one migration at a time. Correctness - across seeds, non-default values, behaviour flips, and data transforms is - verified by the migration parity tests. + ``save_config(config)`` (default-stripping ON, no ``merge_existing``); + centralising the call makes the invariant impossible to regress one + migration at a time. Callers must pass the full raw config returned by + ``read_raw_config()`` after in-place mutations (including key removals); + deep-merging the on-disk file back in would resurrect keys the migration + just deleted. Partial-save preservation for unrelated top-level sections + belongs on ``save_config(..., merge_existing=True)``, not here. """ save_config(config) @@ -6218,12 +6317,37 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A return results +def _merge_partial_save(raw: dict, override: dict) -> dict: + """Merge *override* over *raw* for partial ``save_config`` writes. + + Top-level sections omitted from *override* are preserved from *raw*. + Shared top-level dict sections are deep-merged so a caller can update one + nested key without dropping sibling keys from disk. Intentional key + removals within a section are not supported here — migration writes must + route through ``_persist_migration`` with a full ``read_raw_config()`` dict + instead. + """ + result = copy.deepcopy(override) + for key, value in raw.items(): + if key not in result: + result[key] = copy.deepcopy(value) + elif isinstance(result.get(key), dict) and isinstance(value, dict): + result[key] = _deep_merge(value, result[key]) + return result + + def _deep_merge(base: dict, override: dict) -> dict: """Recursively merge *override* into *base*, preserving nested defaults. Keys in *override* take precedence. If both values are dicts the merge recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will keep the default ``tts.elevenlabs.model_id`` intact. + + An empty section key in config.yaml (``terminal:`` with no value) parses + as YAML ``None``; treating that as an override would replace the entire + default dict with ``None`` and crash every downstream consumer that + expects a mapping (#58277). A ``None`` override of a dict default is + ignored — same as the key being absent. """ result = base.copy() for key, value in override.items(): @@ -6233,6 +6357,8 @@ def _deep_merge(base: dict, override: dict) -> dict: and isinstance(value, dict) ): result[key] = _deep_merge(result[key], value) + elif key in result and isinstance(result[key], dict) and value is None: + continue else: result[key] = value return result @@ -6931,7 +7057,45 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]: config = _deep_merge(config, user_config) except Exception as e: - _warn_config_parse_failure(config_path, e) + # Last-known-good fallback (port of openai/codex#31188's + # invariant: a parse failure in a policy/config file must not + # silently replace the effective policy with an empty/default + # one). Falling through to DEFAULT_CONFIG here drops EVERY user + # override — including security-critical ``approvals.deny`` + # rules, which are supposed to block commands even under yolo. + # A long-running gateway whose user mid-edits config.yaml into + # broken YAML would silently lose those rules on the next load. + # Within a running process we still have the last successfully + # loaded config — keep serving it until the file is fixed. + # Fresh processes with no last-known-good keep the existing + # DEFAULT_CONFIG fallback. + lkg = _LAST_EXPANDED_CONFIG_BY_PATH.get(path_key) + _warn_config_parse_failure( + config_path, + e, + fallback="last-known-good" if lkg is not None else "defaults", + ) + if lkg is not None: + # save_config() stores the pre-expansion normalized dict + # (env-ref templates preserved); the load path stores the + # expanded one. Expand defensively — idempotent when the + # stored value is already expanded. + from typing import cast as _cast + lkg_copy: Dict[str, Any] = _cast( + Dict[str, Any], _expand_env_vars(copy.deepcopy(lkg)) + ) + if cache_sig is not None: + # Cache under the corrupt file's signature (empty env + # snapshot: always valid) so repeated loads don't + # re-parse the broken file; fixing the file changes the + # signature and triggers a normal reload. + _empty_env: Dict[str, Optional[str]] = {} + _LOAD_CONFIG_CACHE[path_key] = ( + cache_sig[0], cache_sig[1], + cache_sig[2], cache_sig[3], + lkg_copy, _empty_env, + ) + return copy.deepcopy(lkg_copy) if want_deepcopy else lkg_copy normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) expanded = _expand_env_vars(normalized) @@ -7054,6 +7218,7 @@ def save_config( *, strip_defaults: bool = True, preserve_keys: Optional[Set[Tuple[str, ...]]] = None, + merge_existing: bool = False, ): """Save configuration to ~/.hermes/config.yaml.\n @@ -7062,6 +7227,12 @@ def save_config( before any normalisation). This prevents config.yaml from being contaminated with schema defaults on every save, which makes future default changes invisible to users. + + When ``merge_existing`` is True, the on-disk raw config is deep-merged + under *config* before writing so partial callers (migration steps via + ``_persist_migration``) cannot drop unrelated sections the caller omitted. + Full-document replacement callers (dashboard raw YAML editor, callers that + already deep-merge) must leave this False so intentional deletions survive. """ with _CONFIG_LOCK: if is_managed(): @@ -7096,11 +7267,17 @@ def save_config( explicit_raw_paths: Optional[Set[Tuple[str, ...]]] = ( _explicit_config_paths(_raw_for_paths) if _raw_for_paths else None ) + if merge_existing and _raw_for_paths: + config = _merge_partial_save(_raw_for_paths, config) # ---------------------------------------------------------------- current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) normalized = current_normalized - raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config())) + raw_existing = ( + _normalize_root_model_keys(_normalize_max_turns_config(_raw_for_paths)) + if _raw_for_paths + else {} + ) if raw_existing: normalized = _preserve_env_ref_templates( normalized, @@ -7148,6 +7325,7 @@ def save_config( extra_content="".join(parts) if parts else None, ) _secure_file(config_path) + _RAW_CONFIG_CACHE.pop(str(config_path), None) _LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized) @@ -8024,6 +8202,20 @@ def edit_config(): subprocess.run([editor, str(config_path)]) +def _default_value_for_key(dotted_key: str): + """Return the leaf value declared for *dotted_key* in ``DEFAULT_CONFIG``. + + Unknown keys and non-leaf paths return ``None`` so they retain the legacy + best-effort coercion used by ``config set``. + """ + node = DEFAULT_CONFIG + for part in dotted_key.split("."): + if not isinstance(node, dict) or part not in node: + return None + node = node[part] + return node if not isinstance(node, dict) else None + + def set_config_value(key: str, value: str): """Set a configuration value.""" if is_managed(): @@ -8081,16 +8273,21 @@ def set_config_value(key: str, value: str): # _set_nested which preserves list-typed nodes; before #17876 the # inline navigation here silently overwrote lists with dicts. - # Convert value to appropriate type - if value.lower() in {'true', 'yes', 'on'}: - value = True - elif value.lower() in {'false', 'no', 'off'}: - value = False - elif value.isdigit(): - value = int(value) - elif value.replace('.', '', 1).isdigit(): - value = float(value) + # Preserve values for string-typed settings. In particular, enum members + # such as approvals.mode="off" must not become YAML booleans. Unknown keys + # retain the historical best-effort coercion behavior. + coerced_value: Any = value + if not isinstance(_default_value_for_key(key), str): + if value.lower() in {'true', 'yes', 'on'}: + coerced_value = True + elif value.lower() in {'false', 'no', 'off'}: + coerced_value = False + elif value.isdigit(): + coerced_value = int(value) + elif value.replace('.', '', 1).isdigit(): + coerced_value = float(value) + value = coerced_value _set_nested(user_config, key, value) # Normalize the api_base → base_url alias at set-time too (issue #8919), # so a fresh `hermes config set model.api_base ...` lands on the canonical diff --git a/hermes_cli/dashboard_auth/base.py b/hermes_cli/dashboard_auth/base.py index 8f376f352108..e8b8a7730b1d 100644 --- a/hermes_cli/dashboard_auth/base.py +++ b/hermes_cli/dashboard_auth/base.py @@ -94,9 +94,11 @@ class InvalidCredentialsError(Exception): class RefreshExpiredError(Exception): - """The refresh token is dead. + """This provider rejects the refresh token as dead or invalid. - Middleware clears cookies and forces re-login (302 → ``/login``). + In a multi-provider deployment this does not prove token ownership, so + middleware may try remaining providers. It clears cookies and forces + re-login only after every reachable provider rejects the token. """ @@ -125,9 +127,13 @@ class DashboardAuthProvider(ABC): raises ``ProviderError`` if the IDP is unreachable. Middleware treats expiry and unreachable differently (expiry → refresh; unreachable → 503). - * ``refresh_session`` raises ``RefreshExpiredError`` when the - refresh token is also invalid; middleware then forces re-login. - Raises ``ProviderError`` on network failure. + * ``refresh_session`` raises ``RefreshExpiredError`` when the refresh + token is invalid for that provider. Middleware tries the remaining + providers because an opaque foreign token can be indistinguishable + from an expired one; it forces re-login only after every reachable + provider rejects the token. Raises ``ProviderError`` on network + failure; middleware still tries remaining providers, but returns 503 + without clearing cookies if none succeeds and any was unavailable. * ``revoke_session`` is best-effort and must not raise. Subclasses MUST set ``name`` (lowercase identifier, stable forever) diff --git a/hermes_cli/dashboard_auth/cookies.py b/hermes_cli/dashboard_auth/cookies.py index ef7f79b27d3f..8bcd9db78eb6 100644 --- a/hermes_cli/dashboard_auth/cookies.py +++ b/hermes_cli/dashboard_auth/cookies.py @@ -66,6 +66,10 @@ from fastapi.responses import Response # request's HTTPS + prefix combination. SESSION_AT_COOKIE = "hermes_session_at" SESSION_RT_COOKIE = "hermes_session_rt" +# Provider that minted the session. This non-secret routing hint prevents a +# refresh token from being handed to the wrong provider when several dashboard +# auth plugins are enabled (for example Basic + Nous OAuth). +SESSION_PROVIDER_COOKIE = "hermes_session_provider" PKCE_COOKIE = "hermes_session_pkce" # One-shot loop-guard marker for the auto-SSO redirect (Phase 1, # cloud-auto-discovery). Set when the gate auto-initiates the portal OAuth @@ -141,6 +145,24 @@ def _common_attrs(*, use_https: bool, prefix: str) -> dict: return attrs +def set_session_provider_cookie( + response: Response, + *, + provider: str, + use_https: bool, + prefix: str = "", +) -> None: + """Persist the non-secret provider routing hint for token refresh.""" + if not provider: + return + response.set_cookie( + _resolved_name(SESSION_PROVIDER_COOKIE, use_https=use_https, prefix=prefix), + provider, + max_age=_RT_MAX_AGE, + **_common_attrs(use_https=use_https, prefix=prefix), + ) + + def set_session_cookies( response: Response, *, @@ -149,6 +171,7 @@ def set_session_cookies( access_token_expires_in: int, use_https: bool, prefix: str = "", + provider: str = "", ) -> None: """Set the session cookies on the response. @@ -181,6 +204,12 @@ def set_session_cookies( max_age=_RT_MAX_AGE, **_common_attrs(use_https=use_https, prefix=prefix), ) + set_session_provider_cookie( + response, + provider=provider, + use_https=use_https, + prefix=prefix, + ) def clear_session_cookies(response: Response, *, prefix: str = "") -> None: @@ -202,6 +231,10 @@ def clear_session_cookies(response: Response, *, prefix: str = "") -> None: f"{variant}{SESSION_RT_COOKIE}", "", max_age=0, path=path, httponly=True, samesite="lax", ) + response.set_cookie( + f"{variant}{SESSION_PROVIDER_COOKIE}", "", max_age=0, + path=path, httponly=True, samesite="lax", + ) def set_pkce_cookie( @@ -248,6 +281,11 @@ def read_session_cookies(request: Request) -> Tuple[Optional[str], Optional[str] return at, rt +def read_session_provider(request: Request) -> Optional[str]: + """Return the provider routing hint associated with the session cookies.""" + return _read_with_fallback(request, SESSION_PROVIDER_COOKIE) + + def read_pkce_cookie(request: Request) -> Optional[str]: return _read_with_fallback(request, PKCE_COOKIE) diff --git a/hermes_cli/dashboard_auth/middleware.py b/hermes_cli/dashboard_auth/middleware.py index 2c5f5b4f7b95..caa1b3a6e5dd 100644 --- a/hermes_cli/dashboard_auth/middleware.py +++ b/hermes_cli/dashboard_auth/middleware.py @@ -24,11 +24,17 @@ from fastapi.responses import JSONResponse, RedirectResponse, Response from hermes_cli.dashboard_auth import list_session_providers from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log -from hermes_cli.dashboard_auth.base import ProviderError, RefreshExpiredError +from hermes_cli.dashboard_auth.base import ( + DashboardAuthProvider, + ProviderError, + RefreshExpiredError, +) from hermes_cli.dashboard_auth.cookies import ( clear_sso_attempt_cookie, read_session_cookies, + read_session_provider, read_sso_attempt_cookie, + set_session_provider_cookie, set_sso_attempt_cookie, ) from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS @@ -83,6 +89,22 @@ def _client_ip(request: Request) -> str: return request.client.host if request.client else "" +def _ordered_session_providers( + provider_hint: str | None, +) -> list[DashboardAuthProvider]: + """Prefer the hinted provider without making the hint authoritative. + + The cookie can outlive a provider rename/removal or become stale after a + deployment change. A stable sort moves a matching provider to the front + while preserving registration order for every remaining candidate; an + unknown hint therefore leaves the normal scan unchanged. + """ + providers = list_session_providers() + if provider_hint: + providers.sort(key=lambda provider: provider.name != provider_hint) + return providers + + def _unauth_response(request: Request, *, reason: str) -> Response: """API routes → 401 JSON with ``login_url``; HTML routes → 302 → /login. @@ -150,6 +172,8 @@ def _auto_sso_response(request: Request) -> Response | None: * exactly ONE interactive provider is registered — with two or more we can't pick for the user, so the ``/login`` chooser must render; with zero there's nothing to redirect to; + * that provider is OAuth-style, not a password form provider. Password + providers must render ``/login`` so the user can enter credentials; * the one-shot loop-guard marker is ABSENT. Its presence means we already bounced to the portal once and came back still unauthenticated (no portal session) — auto-redirecting again would @@ -185,6 +209,9 @@ def _auto_sso_response(request: Request) -> Response | None: from hermes_cli.dashboard_auth.prefix import prefix_from_request provider = providers[0] + if getattr(provider, "supports_password", False): + return None + prefix = prefix_from_request(request) next_param = _safe_next_target(request) from urllib.parse import quote @@ -271,6 +298,7 @@ async def gated_auth_middleware( return await call_next(request) at, _rt = read_session_cookies(request) + provider_hint = read_session_provider(request) if not at and not _rt: # Neither token present — no session at all. Nothing to verify or # refresh. Before falling back to the /login interstitial, try to @@ -316,7 +344,7 @@ async def gated_auth_middleware( # 503 — distinguishing "transient IDP outage" (don't force re-login) # from "token genuinely invalid" (fall through to refresh/relogin). unreachable_provider: str | None = None - for provider in list_session_providers(): + for provider in _ordered_session_providers(provider_hint): try: session = provider.verify_session(access_token=at) except ProviderError as e: @@ -348,9 +376,22 @@ async def gated_auth_middleware( # Access token is expired/invalid. Before forcing re-login, try to # rotate it using the refresh token (if the session cookie carries # one). On success we re-set the rotated cookies on the response and - # serve the request transparently; on RefreshExpiredError (RT dead / - # revoked / reuse-detected) we fall through to clear-and-relogin. - refreshed = _attempt_refresh(request, refresh_token=_rt) + # serve the request transparently; only after every provider rejects + # the RT do we fall through to clear-and-relogin. + try: + refreshed = _attempt_refresh( + request, + refresh_token=_rt, + provider_hint=provider_hint, + ) + except ProviderError as e: + # At least one provider could not confirm or reject the RT, and no + # other provider refreshed it. Preserve the cookies and surface a + # transient outage instead of turning uncertainty into a logout. + return JSONResponse( + {"detail": f"Auth provider {str(e)!r} unreachable"}, + status_code=503, + ) if refreshed is not None: new_session, refreshing_provider = refreshed request.state.session = new_session @@ -373,6 +414,7 @@ async def gated_auth_middleware( access_token_expires_in=_expires_in_seconds(new_session), use_https=detect_https(request), prefix=prefix_from_request(request), + provider=refreshing_provider, ) audit_log( AuditEvent.REFRESH_SUCCESS, @@ -400,7 +442,18 @@ async def gated_auth_middleware( return response request.state.session = session - return await call_next(request) + response = await call_next(request) + if not provider_hint and session.provider: + from hermes_cli.dashboard_auth.cookies import detect_https + from hermes_cli.dashboard_auth.prefix import prefix_from_request + + set_session_provider_cookie( + response, + provider=session.provider, + use_https=detect_https(request), + prefix=prefix_from_request(request), + ) + return response def _expires_in_seconds(session) -> int: @@ -416,33 +469,32 @@ def _expires_in_seconds(session) -> int: return max(60, int(session.expires_at) - int(time.time())) -def _attempt_refresh(request: Request, *, refresh_token): +def _attempt_refresh(request: Request, *, refresh_token, provider_hint: str | None = None): """Try to rotate an expired session via the refresh token. - Returns ``(new_session, provider_name)`` on success, or ``None`` if - there's no RT or every provider's ``refresh_session`` failed with - ``RefreshExpiredError`` (dead/revoked/reuse-detected RT → force re-login). - - A ``ProviderError`` (Portal unreachable) is NOT swallowed into a re-login - here — re-raising would 500 the request; instead we log and return None so - the caller forces a clean re-login, which is the safer UX than a hard - error on a transient network blip during the narrow refresh window. + The provider hint only changes candidate order. ``RefreshExpiredError`` + rejects the token for that candidate, but cannot prove ownership because + providers such as Basic raise it for foreign opaque tokens too. Likewise, + ``ProviderError`` only makes that candidate unavailable. Both are audited + and the remaining providers are tried. Returns ``None`` only when there is + no RT or every reachable provider rejects it. If no provider succeeds and + at least one raised ``ProviderError``, re-raises with that provider's name + so the caller can return 503 without clearing potentially valid cookies. """ if not refresh_token: return None - for provider in list_session_providers(): + unavailable_provider: str | None = None + for provider in _ordered_session_providers(provider_hint): try: new_session = provider.refresh_session(refresh_token=refresh_token) except RefreshExpiredError: - # This provider owns the RT but it's dead — stop trying others - # (an RT belongs to exactly one provider) and force re-login. audit_log( AuditEvent.REFRESH_FAILURE, provider=provider.name, reason="refresh_expired", ip=_client_ip(request), ) - return None + continue except ProviderError as e: _log.warning( "dashboard-auth: provider %r unreachable during refresh: %s", @@ -454,8 +506,11 @@ def _attempt_refresh(request: Request, *, refresh_token): reason="provider_unreachable", ip=_client_ip(request), ) - return None + if unavailable_provider is None: + unavailable_provider = provider.name + continue if new_session is not None: return new_session, provider.name + if unavailable_provider is not None: + raise ProviderError(unavailable_provider) return None - diff --git a/hermes_cli/dashboard_auth/routes.py b/hermes_cli/dashboard_auth/routes.py index 9e80f2583c5b..5b833e5df79a 100644 --- a/hermes_cli/dashboard_auth/routes.py +++ b/hermes_cli/dashboard_auth/routes.py @@ -192,6 +192,14 @@ async def auth_login(request: Request, provider: str, next: str = ""): status_code=404, detail=f"Provider does not support interactive login: {provider!r}", ) + if getattr(p, "supports_password", False): + from urllib.parse import quote + + safe_next = _validate_post_login_target(next) + login_url = f"{_prefix(request)}/login" + if safe_next: + login_url = f"{login_url}?next={quote(safe_next, safe='')}" + return RedirectResponse(url=login_url, status_code=302) try: ls = p.start_login(redirect_uri=_redirect_uri(request)) @@ -357,6 +365,7 @@ async def auth_callback( access_token_expires_in=expires_in, use_https=detect_https(request), prefix=_prefix(request), + provider=session.provider, ) clear_pkce_cookie(resp, prefix=_prefix(request)) # Clear the one-shot auto-SSO loop-guard marker now that login succeeded, @@ -541,6 +550,7 @@ async def auth_password_login(request: Request, body: _PasswordLoginBody): access_token_expires_in=expires_in, use_https=detect_https(request), prefix=_prefix(request), + provider=session.provider, ) return resp diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 12b688b224cd..6e94996c2740 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -30,6 +30,7 @@ from utils import base_url_host_matches _PROVIDER_ENV_HINTS = ( + "DEEPINFRA_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", @@ -42,6 +43,7 @@ _PROVIDER_ENV_HINTS = ( "KIMI_API_KEY", "KIMI_CN_API_KEY", "GMI_API_KEY", + "FIREWORKS_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "KILOCODE_API_KEY", @@ -845,6 +847,14 @@ def run_doctor(args): "lmstudio", "nous", "nvidia", + # Fireworks' native model IDs are slash-form + # (accounts/fireworks/models/... and .../routers/...), so a "/" + # is expected, not an aggregator vendor prefix. + "fireworks", + # DeepInfra is an aggregator-style gateway: its catalog + # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…, + # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …). + "deepinfra", } provider_accepts_vendor_slug = ( provider_policy_id in providers_accepting_vendor_slugs diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index ab743b281f19..7b9817eadd8f 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -938,6 +938,32 @@ def _read_systemd_unit_environment(system: bool = False) -> dict[str, str]: return parsed +def _hermes_home_from_systemd_unit_file(system: bool = False) -> str | None: + """Read ``HERMES_HOME`` from the on-disk unit file (not ``systemctl show``). + + Prefer the file when refreshing/comparing: under ``sudo``, ``systemctl`` + may be slow/unavailable in tests, and the on-disk unit is what + ``systemd_unit_is_current`` / ``refresh_systemd_unit_if_needed`` already + compare against. + """ + unit_path = get_systemd_unit_path(system=system) + if not unit_path.exists(): + return None + try: + text = unit_path.read_text(encoding="utf-8") + except OSError: + return None + for line in text.splitlines(): + stripped = line.strip() + if not stripped.startswith("Environment="): + continue + body = stripped[len("Environment=") :].strip().strip('"') + if body.startswith("HERMES_HOME="): + value = body.split("=", 1)[1].strip().strip('"') + return value or None + return None + + def _sync_hermes_home_from_systemd_unit(system: bool) -> None: """When acting on a system-scope unit, adopt its ``HERMES_HOME``. @@ -949,8 +975,11 @@ def _sync_hermes_home_from_systemd_unit(system: bool) -> None: """ if not system: return - env = _read_systemd_unit_environment(system=True) - unit_home = env.get("HERMES_HOME", "").strip() + # Prefer the on-disk unit (source of truth for refresh/compare). Fall + # back to ``systemctl show`` for units that only exist in the manager. + unit_home = (_hermes_home_from_systemd_unit_file(system=True) or "").strip() + if not unit_home: + unit_home = _read_systemd_unit_environment(system=True).get("HERMES_HOME", "").strip() if not unit_home: return current = os.environ.get("HERMES_HOME", "").strip() @@ -2826,6 +2855,24 @@ def _normalize_launchd_plist_for_comparison(text: str) -> str: def systemd_unit_is_current(system: bool = False) -> bool: + # ── HERMES_HOME sync chokepoint ────────────────────────────────────── + # Every path that compares OR regenerates the unit funnels through here: + # ``refresh_systemd_unit_if_needed`` gates on this before rewriting, and + # ``systemd_status`` / ``systemd_install`` call it directly. Doing the + # sync here — and ONLY here — enforces the invariant "the operator's + # pinned HERMES_HOME is adopted before any compare/regenerate" at a single + # site, so a future callsite cannot regress it by forgetting to pre-sync. + # + # Under ``sudo hermes gateway … --system``, HERMES_HOME is often stripped + # and falls back to ``/root/.hermes``. Adopting the unit's pinned home + # first makes TimeoutStopSec / WorkingDirectory / HERMES_HOME comparisons + # use the real operator config — otherwise start/restart "refresh" rewrites + # a correct unit from root's defaults and ``status`` keeps warning forever. + # ``_sync_...`` is idempotent (early-returns once os.environ matches), so + # the mutation persists for callers that read runtime state after this + # (e.g. ``systemd_restart``'s post-refresh get_running_pid / drain-timeout). + _sync_hermes_home_from_systemd_unit(system=system) + unit_path = get_systemd_unit_path(system=system) if not unit_path.exists(): return False @@ -2907,7 +2954,14 @@ def _refuse_temp_home_service_write(definition: str, kind: str) -> bool: def refresh_systemd_unit_if_needed(system: bool = False) -> bool: """Rewrite the installed systemd unit when the generated definition has changed.""" unit_path = get_systemd_unit_path(system=system) - if not unit_path.exists() or systemd_unit_is_current(system=system): + if not unit_path.exists(): + return False + + # The gate below funnels through ``systemd_unit_is_current``, which is the + # single HERMES_HOME-sync chokepoint (adopts the unit's pinned home before + # any compare/regenerate). No separate pre-sync needed here — and the env + # mutation it performs persists for the regenerate path below. + if systemd_unit_is_current(system=system): return False expected_user = _read_systemd_user_from_unit(unit_path) if system else None @@ -3094,6 +3148,15 @@ def systemd_install( unit_path = get_systemd_unit_path(system=system) scope_flag = " --system" if system else "" + # Existing system units already pin HERMES_HOME; adopt it before any + # regenerate. This pre-sync is NOT redundant with the systemd_unit_is_current + # chokepoint: the ``--force`` path below skips the is_current gate and calls + # generate_systemd_unit() directly (line ~3172), so without this a + # ``sudo hermes gateway install --system --force`` would bake /root/.hermes + # into an already-correct unit. Keep it to protect that bypass path. + if unit_path.exists(): + _sync_hermes_home_from_systemd_unit(system=system) + if unit_path.exists() and not force: if not systemd_unit_is_current(system=system): print( @@ -3184,6 +3247,9 @@ def systemd_start(system: bool = False): # Raises UserSystemdUnavailableError with a remediation message. _preflight_user_systemd() _require_service_installed("start", system=system) + # HERMES_HOME sync happens inside refresh_systemd_unit_if_needed's + # systemd_unit_is_current gate (the single chokepoint), and the unit is + # guaranteed to exist here by _require_service_installed, so the gate runs. refresh_systemd_unit_if_needed(system=system) _run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30) print(f"✓ {_service_scope_label(system).capitalize()} service started") @@ -3224,8 +3290,12 @@ def systemd_restart(system: bool = False): else: _preflight_user_systemd() _require_service_installed("restart", system=system) + # HERMES_HOME sync happens inside refresh_systemd_unit_if_needed's + # systemd_unit_is_current gate (the single chokepoint). The unit exists + # here (_require_service_installed), so the gate runs and its os.environ + # mutation persists for the get_running_pid / drain-timeout reads below — + # no separate pre-sync needed. refresh_systemd_unit_if_needed(system=system) - _sync_hermes_home_from_systemd_unit(system=system) from gateway.status import get_running_pid pid = get_running_pid() or _systemd_main_pid(system=system) @@ -3326,8 +3396,6 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) print(f" Run: {'sudo ' if system else ''}hermes gateway install{scope_flag}") return - _sync_hermes_home_from_systemd_unit(system=system) - if has_conflicting_systemd_units(): print_systemd_scope_conflict_warning() print() diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index 3a1e869308ab..21e055402293 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -878,20 +878,11 @@ def judge_goal( return "continue", "empty response (nothing to evaluate)", False, None try: - from agent.auxiliary_client import get_auxiliary_extra_body, get_text_auxiliary_client + from agent.auxiliary_client import call_llm except Exception as exc: logger.debug("goal judge: auxiliary client import failed: %s", exc) return "continue", "auxiliary client unavailable", False, None - try: - client, model = get_text_auxiliary_client("goal_judge") - except Exception as exc: - logger.debug("goal judge: get_text_auxiliary_client failed: %s", exc) - return "continue", "auxiliary client unavailable", False, None - - if client is None or not model: - return "continue", "no auxiliary client configured", False, None - # Build the prompt. Priority: contract > subgoals > plain. When both a # contract and subgoals exist, the subgoals are appended into the # contract block as extra criteria so the judge sees a single source of @@ -935,8 +926,11 @@ def judge_goal( ) try: - resp = client.chat.completions.create( - model=model, + # Route through call_llm so auxiliary.goal_judge.* config + # (provider/model/base_url, extra_body, reasoning_effort, retries) + # all apply — the direct-create path dropped extra_body (#35566). + resp = call_llm( + task="goal_judge", messages=[ {"role": "system", "content": JUDGE_SYSTEM_PROMPT}, {"role": "user", "content": prompt}, @@ -944,7 +938,6 @@ def judge_goal( temperature=0, max_tokens=_goal_judge_max_tokens(), timeout=timeout, - extra_body=get_auxiliary_extra_body() or None, ) except Exception as exc: logger.info("goal judge: API call failed (%s) — falling through to continue", exc) @@ -999,23 +992,15 @@ def draft_contract(objective: str, *, timeout: float = DEFAULT_JUDGE_TIMEOUT) -> return None try: - from agent.auxiliary_client import get_auxiliary_extra_body, get_text_auxiliary_client + from agent.auxiliary_client import call_llm except Exception as exc: logger.debug("goal draft: auxiliary client import failed: %s", exc) return None try: - client, model = get_text_auxiliary_client("goal_judge") - except Exception as exc: - logger.debug("goal draft: get_text_auxiliary_client failed: %s", exc) - return None - - if client is None or not model: - return None - - try: - resp = client.chat.completions.create( - model=model, + # Route through call_llm — same #35566 fix as the judge call above. + resp = call_llm( + task="goal_judge", messages=[ {"role": "system", "content": DRAFT_CONTRACT_SYSTEM_PROMPT}, {"role": "user", "content": f"Objective:\n{_truncate(objective, 4000)}"}, @@ -1023,7 +1008,6 @@ def draft_contract(objective: str, *, timeout: float = DEFAULT_JUDGE_TIMEOUT) -> temperature=0, max_tokens=_goal_judge_max_tokens(), timeout=timeout, - extra_body=get_auxiliary_extra_body() or None, ) except Exception as exc: logger.info("goal draft: API call failed (%s)", exc) diff --git a/hermes_cli/input_sanitize.py b/hermes_cli/input_sanitize.py new file mode 100644 index 000000000000..90ca4c4d3966 --- /dev/null +++ b/hermes_cli/input_sanitize.py @@ -0,0 +1,70 @@ +"""Sanitize user prompt text leaked from terminal / paste control sequences.""" + +from __future__ import annotations + +import re + +_BRACKETED_PASTE_BOUNDARY_START = re.compile(r"(^|[\s\n>:\]\)])\[200~") +_BRACKETED_PASTE_BOUNDARY_END = re.compile(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])") +_BRACKETED_PASTE_DEGRADED_START = re.compile(r"(^|[\s\n>:\]\)])00~") +_BRACKETED_PASTE_DEGRADED_END = re.compile(r"01~(?=$|[\s\n<\[\(\):;.,!?])") + +# Corruption signature from desktop bracketed-paste leaks (#62557). +_DESKTOP_PASTE_ARTIFACT = "~[[e" + + +def strip_leaked_bracketed_paste_wrappers(text: str) -> str: + """Strip leaked bracketed-paste wrapper markers from user-visible text. + + Defensive normalization for cases where terminal/prompt_toolkit parsing + fails and bracketed-paste markers end up in the buffer as literal text. + + Canonical wrappers are stripped unconditionally. Degraded visible forms like + ``[200~`` / ``[201~`` and ``00~`` / ``01~`` are removed only at boundaries + so embedded literals such as ``literal[200~tag`` stay intact. + """ + if not text: + return text + + text = ( + text.replace("\x1b[200~", "") + .replace("\x1b[201~", "") + .replace("^[[200~", "") + .replace("^[[201~", "") + ) + text = _BRACKETED_PASTE_BOUNDARY_START.sub(r"\1", text) + text = _BRACKETED_PASTE_BOUNDARY_END.sub("", text) + text = _BRACKETED_PASTE_DEGRADED_START.sub(r"\1", text) + text = _BRACKETED_PASTE_DEGRADED_END.sub("", text) + return text + + +def collapse_repeated_input_artifacts(text: str, min_repeats: int = 4) -> str: + """Drop a trailing run of the desktop ~[[e corruption signature (#62557).""" + if not text: + return text + + marker = _DESKTOP_PASTE_ARTIFACT + index = len(text) + repeat_count = 0 + while index >= len(marker) and text[index - len(marker) : index] == marker: + repeat_count += 1 + index -= len(marker) + + if repeat_count < min_repeats: + return text + + start = index + if start >= 2 and text[start - 2 : start] == "[e": + start -= 2 + elif start >= 1 and text[start - 1] == "[": + start -= 1 + return text[:start] + + +def sanitize_user_prompt_text(text: str) -> str: + """Normalize user-authored prompt text before persistence or model input.""" + if not isinstance(text, str) or not text: + return text + cleaned = strip_leaked_bracketed_paste_wrappers(text) + return collapse_repeated_input_artifacts(cleaned) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 08cfe322b878..cd90d2a9a9e7 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -187,6 +187,14 @@ def build_models_payload( if explicit_only: rows = _filter_explicit_provider_rows(rows, ctx) + # Desktop chat pickers request the explicit subset without the full + # unconfigured provider universe. If the configured current provider + # has lost its credential, list_authenticated_providers() omits it; + # keep that one row visible so the UI can show the saved selection and + # a re-auth affordance instead of appearing to jump to another provider. + rows = list(rows) + _append_unconfigured_rows( + rows, ctx, current_only=True + ) # --- Deduplicate: remove models from aggregators that overlap with # user-defined providers. When a local proxy (e.g. litellm-proxy) @@ -292,16 +300,62 @@ def _apply_capabilities(rows: list[dict]) -> None: # ─── Internal: row post-processing ────────────────────────────────────── -def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]: - """Build skeleton rows for canonical providers missing from ``rows``.""" +def _append_unconfigured_rows( + rows: list[dict], + ctx: ConfigContext, + *, + current_only: bool = False, +) -> list[dict]: + """Build fallback rows for canonical providers missing from ``rows``. + + Most missing canonical providers become empty setup skeletons. The one + exception is the *current* configured provider: if config.yaml still points + at it but credentials are presently unavailable, keep a visible row carrying + the saved model so GUI pickers don't silently snap to some other provider. + """ + from hermes_cli.auth import PROVIDER_REGISTRY from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS seen = {r["slug"].lower() for r in rows} cur = (ctx.current_provider or "").lower() + cur_model = str(ctx.current_model or "").strip() extras: list[dict] = [] for entry in CANONICAL_PROVIDERS: if entry.slug.lower() in seen: continue + if current_only and entry.slug.lower() != cur: + continue + if entry.slug.lower() == cur: + cfg = PROVIDER_REGISTRY.get(entry.slug) + auth_type = cfg.auth_type if cfg else "api_key" + key_env = ( + cfg.api_key_env_vars[0] + if (cfg and cfg.api_key_env_vars) + else "" + ) + warning = ( + f"Configured provider missing usable credentials; paste {key_env} to reactivate. " + "Showing the saved model only." + if auth_type == "api_key" and key_env + else "Configured provider is not authenticated; run `hermes model` to reactivate. " + "Showing the saved model only." + ) + extras.append( + { + "slug": entry.slug, + "name": _PROVIDER_LABELS.get(entry.slug, entry.label), + "is_current": True, + "is_user_defined": False, + "models": [cur_model] if cur_model else [], + "total_models": 1 if cur_model else 0, + "source": "configured-current", + "authenticated": False, + "auth_type": auth_type, + "key_env": key_env, + "warning": warning, + } + ) + continue extras.append( { "slug": entry.slug, @@ -340,13 +394,61 @@ def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list if slug == "moa": # MoA is a virtual routing mode, not an independently configured # provider. Hide it from explicit-only pickers unless it is the - # current provider (handled above). + # current provider (handled above) or the user explicitly wrote an + # enabled MoA preset into config.yaml. Use raw config so the + # DEFAULT_CONFIG preset does not make every desktop picker show MoA. + if _raw_config_has_enabled_moa_preset(): + kept.append(row) continue if is_provider_explicitly_configured(slug): kept.append(row) return kept +def _raw_config_has_enabled_moa_preset() -> bool: + """Return True when the user's raw config explicitly enables MoA. + + ``load_config()`` includes ``DEFAULT_CONFIG["moa"].presets.default`` for + everyone. Explicit-only model pickers must not treat that default as a user + choice, but they should keep MoA visible once the user has saved at least + one enabled preset (or an older flat MoA config) in their own config.yaml. + """ + try: + from hermes_cli.config import read_raw_config + + raw = read_raw_config() + except Exception: + return False + + if not isinstance(raw, dict): + return False + moa = raw.get("moa") + if not isinstance(moa, dict): + return False + + presets = moa.get("presets") + if isinstance(presets, dict): + for name, preset in presets.items(): + if not str(name or "").strip(): + continue + if not isinstance(preset, dict): + return True + if preset.get("enabled", True): + return True + return False + + legacy_keys = { + "reference_models", + "aggregator", + "reference_temperature", + "aggregator_temperature", + "max_tokens", + "reference_max_tokens", + "fanout", + } + return any(key in moa for key in legacy_keys) and bool(moa.get("enabled", True)) + + def _apply_picker_hints(rows: list[dict]) -> None: """Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row. diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 6150b141537b..39c434d4021d 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -135,6 +135,7 @@ BLOCK_RECURRENCE_LIMIT = 2 VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names()) _IS_WINDOWS = sys.platform == "win32" +KANBAN_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024 def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None: @@ -3975,6 +3976,10 @@ class HallucinatedCardsError(ValueError): ) +class ArtifactPreservationError(RuntimeError): + """Raised when a declared scratch deliverable cannot be preserved.""" + + def complete_task( conn: sqlite3.Connection, task_id: str, @@ -4042,6 +4047,9 @@ def complete_task( else: verified_cards = [] + metadata = _merge_completion_prose_artifacts( + conn, task_id, metadata, summary=summary, result=result, + ) with write_txn(conn): if expected_run_id is None: cur = conn.execute( @@ -4080,6 +4088,18 @@ def complete_task( ) if cur.rowcount != 1: return False + if isinstance(metadata, dict): + _persist_scratch_completion_artifacts(conn, task_id, metadata) + for stored_path in metadata.pop("_staged_artifacts", []): + path = Path(stored_path) + _insert_completion_attachment( + conn, + task_id, + filename=path.name, + stored_path=str(path), + size=path.stat().st_size, + created_at=now, + ) run_id = _end_run( conn, task_id, outcome="completed", status="done", @@ -4174,6 +4194,256 @@ def complete_task( # Workspace / tmux cleanup # --------------------------------------------------------------------------- + +def _merge_completion_prose_artifacts( + conn: sqlite3.Connection, + task_id: str, + metadata: Optional[dict], + *, + summary: Optional[str], + result: Optional[str], +) -> Optional[dict]: + """Promote existing scratch files named in legacy completion prose. + + ``artifacts=[...]`` is preferred. Older workers only wrote an absolute + deliverable path in ``summary``/``result``; discover it while scratch still + exists so cleanup cannot erase the file the user was promised. + """ + row = conn.execute( + "SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if not row or row["workspace_kind"] != "scratch" or not row["workspace_path"]: + return metadata + workspace = Path(row["workspace_path"]).expanduser() + if not _is_managed_scratch_path(workspace): + return metadata + text = "\n".join(part for part in (summary, result) if part) + if not text: + return metadata + prefix = re.escape(str(workspace)) + discovered: list[str] = [] + for match in re.finditer(prefix + r"(?:[/\\][^\s`\"'<>]+)", text): + raw = match.group(0).rstrip(".,;:!?)]}") + candidate = Path(raw) + if candidate.is_file(): + discovered.append(str(candidate)) + if not discovered: + return metadata + updated = dict(metadata) if isinstance(metadata, dict) else {} + existing = updated.get("artifacts") + merged = list(existing) if isinstance(existing, (list, tuple)) else [] + seen = {str(path) for path in merged} + for path in discovered: + if path not in seen: + merged.append(path) + seen.add(path) + updated["artifacts"] = merged + return updated + + +def _persist_scratch_completion_artifacts( + conn: sqlite3.Connection, + task_id: str, + metadata: dict, +) -> None: + """Copy scratch-workspace completion artifacts before cleanup removes them.""" + raw_artifacts = metadata.get("artifacts") + if not isinstance(raw_artifacts, (list, tuple)): + return + + row = conn.execute( + "SELECT workspace_kind, workspace_path FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if not row or row["workspace_kind"] != "scratch" or not row["workspace_path"]: + return + + workspace = Path(row["workspace_path"]).expanduser() + is_managed, board = _managed_scratch_path_info(workspace) + if not is_managed: + return + + try: + workspace_root = workspace.resolve() + except OSError: + return + + attachment_dir = task_attachments_dir(task_id, board=board) + persisted: list[str] = [] + used_destinations: set[Path] = set() + changed = False + + def _discard_copies() -> None: + for copied in used_destinations: + try: + copied.unlink(missing_ok=True) + except OSError: + pass + try: + attachment_dir.rmdir() + except OSError: + pass + + for item in raw_artifacts: + artifact = str(item).strip() if isinstance(item, str) else "" + if not artifact: + continue + src = Path(artifact).expanduser() + try: + resolved_src = src.resolve() + except OSError: + persisted.append(artifact) + continue + + if not resolved_src.is_relative_to(workspace_root): + persisted.append(artifact) + continue + + if not src.is_file(): + _discard_copies() + raise ArtifactPreservationError( + f"declared scratch artifact is unavailable or not a regular file: {artifact}" + ) + + size = resolved_src.stat().st_size + if size > KANBAN_ATTACHMENT_MAX_BYTES: + _discard_copies() + raise ArtifactPreservationError( + f"declared scratch artifact exceeds the " + f"{KANBAN_ATTACHMENT_MAX_BYTES}-byte limit: {artifact}" + ) + + dest: Optional[Path] = None + try: + attachment_dir.mkdir(parents=True, exist_ok=True) + dest = _unique_attachment_path(attachment_dir, resolved_src.name, used_destinations) + with resolved_src.open("rb") as source_file, dest.open("xb") as destination_file: + copied = 0 + while chunk := source_file.read(1024 * 1024): + copied += len(chunk) + if copied > KANBAN_ATTACHMENT_MAX_BYTES: + raise ArtifactPreservationError( + f"declared scratch artifact grew beyond the size limit: {artifact}" + ) + destination_file.write(chunk) + except Exception as exc: + if dest is not None: + try: + dest.unlink(missing_ok=True) + except OSError: + pass + _discard_copies() + if isinstance(exc, ArtifactPreservationError): + raise + raise ArtifactPreservationError( + f"could not preserve declared scratch artifact {artifact}: {exc}" + ) from exc + + used_destinations.add(dest) + persisted.append(str(dest.resolve())) + changed = True + + if changed: + metadata["artifacts"] = persisted + metadata["_staged_artifacts"] = [ + path for path in persisted if path.startswith(str(attachment_dir.resolve())) + ] + + +def _insert_completion_attachment( + conn: sqlite3.Connection, + task_id: str, + *, + filename: str, + stored_path: str, + size: int, + created_at: int, +) -> None: + """Record a worker-produced artifact in the existing attachment table.""" + conn.execute( + "INSERT INTO task_attachments " + "(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) " + "VALUES (?, ?, ?, NULL, ?, 'kanban_complete', ?)", + (task_id, filename, stored_path, size, created_at), + ) + _append_event( + conn, + task_id, + "attached", + {"filename": filename, "size": size, "by": "kanban_complete"}, + ) + + +def _unique_attachment_path(directory: Path, filename: str, used: set[Path]) -> Path: + """Return a non-conflicting path under ``directory`` for ``filename``.""" + safe_name = Path(filename).name or "artifact" + candidate = directory / safe_name + if candidate not in used and not candidate.exists(): + return candidate + + stem = Path(safe_name).stem or "artifact" + suffix = Path(safe_name).suffix + idx = 1 + while True: + candidate = directory / f"{stem}_{idx}{suffix}" + if candidate not in used and not candidate.exists(): + return candidate + idx += 1 + + +def _managed_scratch_path_info(p: Path) -> tuple[bool, Optional[str]]: + """Return whether *p* is managed scratch storage and the matching board.""" + try: + p_abs = p.resolve(strict=False) + except OSError: + return False, None + roots: list[tuple[Path, Optional[str]]] = [] + override = os.environ.get("HERMES_KANBAN_WORKSPACES_ROOT", "").strip() + if override: + try: + roots.append((Path(override).expanduser().resolve(strict=False), None)) + except OSError: + pass + try: + home = kanban_home() + except OSError: + home = None + if home is not None: + try: + roots.append(((home / "kanban" / "workspaces").resolve(strict=False), DEFAULT_BOARD)) + except OSError: + pass + try: + boards_parent = (home / "kanban" / "boards").resolve(strict=False) + except OSError: + boards_parent = None + if boards_parent is not None: + try: + entries = list(boards_parent.iterdir()) + except OSError: + entries = [] + for entry in entries: + try: + if not entry.is_dir(): + continue + except OSError: + continue + try: + roots.append(((entry / "workspaces").resolve(strict=False), entry.name)) + except OSError: + continue + for root, board in roots: + if p_abs == root: + continue + try: + if p_abs.is_relative_to(root): + return True, board + except ValueError: + continue + return False, None + + def _is_managed_scratch_path(p: Path) -> bool: """Return True iff *p* is a strict descendant of a kanban-managed scratch root. @@ -4199,54 +4469,8 @@ def _is_managed_scratch_path(p: Path) -> bool: real source tree can otherwise pair with ``workspace_kind='scratch'`` and cause task completion to delete user data (#28818). """ - try: - p_abs = p.resolve(strict=False) - except OSError: - return False - roots: list[Path] = [] - override = os.environ.get("HERMES_KANBAN_WORKSPACES_ROOT", "").strip() - if override: - try: - roots.append(Path(override).expanduser().resolve(strict=False)) - except OSError: - pass - try: - home = kanban_home() - except OSError: - home = None - if home is not None: - try: - roots.append((home / "kanban" / "workspaces").resolve(strict=False)) - except OSError: - pass - try: - boards_parent = (home / "kanban" / "boards").resolve(strict=False) - except OSError: - boards_parent = None - if boards_parent is not None: - try: - entries = list(boards_parent.iterdir()) - except OSError: - entries = [] - for entry in entries: - try: - if not entry.is_dir(): - continue - except OSError: - continue - try: - roots.append((entry / "workspaces").resolve(strict=False)) - except OSError: - continue - for root in roots: - if p_abs == root: - continue - try: - if p_abs.is_relative_to(root): - return True - except ValueError: - continue - return False + is_managed, _board = _managed_scratch_path_info(p) + return is_managed def _cleanup_workspace(conn: sqlite3.Connection, task_id: str) -> None: @@ -6343,6 +6567,77 @@ def _error_fingerprint(error_text: str) -> str: return fp.lower().strip() +# Empirically ~96% of "clean exit without a terminal tool call" tasks complete +# on a later run (a goal-mode finalize nudge, or the model simply emitting the +# tool call next time), so a protocol violation is NOT deterministic — give it a +# bounded retry before the breaker trips instead of blocking on the first hit. +# +# The budget is a violation-only STREAK, not a share of the unified +# ``consecutive_failures`` counter: it counts consecutive clean-exit protocol +# violations (derived from run history by ``_protocol_violation_streak``), so +# earlier timeouts / nonzero exits neither consume nor extend it, and a +# below-budget violation does not tick the unified counter either. A per-task +# ``max_retries`` overrides this bound — the same "task override wins" +# precedence ``_record_task_failure`` documents for every other failure kind. +_PROTOCOL_VIOLATION_FAILURE_LIMIT = 3 + +# How far back to walk a task's closed runs when counting the violation +# streak. The streak trips at a handful of violations, so anything beyond a +# few dozen rows (violations interleaved with neutral rate-limited requeues) +# can only mean "way past the bound" anyway. +_PROTOCOL_VIOLATION_SCAN_LIMIT = 50 + + +def _protocol_violation_streak(conn: sqlite3.Connection, task_id: str) -> int: + """Count the task's trailing run of clean-exit protocol violations. + + Walks the task's closed runs newest-first — including the violation run + ``detect_crashed_workers`` just closed — and counts how many in a row were + clean-exit protocol violations: + + * ``rate_limited`` runs are neutral and skipped: a quota wall says nothing + about the task, exactly as it is neutral for the unified + ``consecutive_failures`` counter. + * Any other closed run (completed, plain crash, timeout, spawn failure, + reclaim, …) breaks the streak, so the bounded retry budget counts ONLY + protocol violations — mixed failure kinds can neither consume nor + extend it. + + Violation runs are recognized by the ``protocol_violation`` marker that + ``detect_crashed_workers`` stamps into the run metadata; the violation + error text is matched as a fallback for runs recorded before the marker + existed. + """ + streak = 0 + rows = conn.execute( + "SELECT outcome, error, metadata FROM task_runs " + "WHERE task_id = ? AND ended_at IS NOT NULL " + "ORDER BY id DESC LIMIT ?", + (task_id, _PROTOCOL_VIOLATION_SCAN_LIMIT), + ).fetchall() + for row in rows: + outcome = row["outcome"] or "" + if outcome == "rate_limited": + continue + if outcome == "crashed": + is_violation = False + raw_meta = row["metadata"] + if raw_meta: + try: + is_violation = bool( + json.loads(raw_meta).get("protocol_violation") + ) + except (ValueError, TypeError): + is_violation = False + if not is_violation: + is_violation = "protocol violation" in (row["error"] or "") + if is_violation: + streak += 1 + continue + break + return streak + + def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]: """Reclaim ``running`` tasks whose worker PID is no longer alive. @@ -6376,8 +6671,9 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]: # Per-crash details collected inside the main txn, used after it # closes to run ``_record_task_failure`` (which needs its own # write_txn so can't nest). ``protocol_violation`` flags the - # clean-exit-but-still-running case so we can trip the breaker - # immediately instead of incrementing by 1. + # clean-exit-but-still-running case, which is accounted against its + # own bounded violation streak instead of the unified failure + # counter (see the post-txn loop below). crash_details: list[tuple[str, int, str, bool, str]] = [] # (task_id, pid, claimer, protocol_violation, error_text) with write_txn(conn): @@ -6408,18 +6704,29 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]: if kind == "clean_exit": # Worker subprocess returned 0 but its task is still # ``running`` in the DB — it exited without calling - # ``kanban_complete`` / ``kanban_block``. Retrying won't - # help. + # ``kanban_complete`` / ``kanban_block``. Overwhelmingly the + # work itself succeeded and only the paperwork was skipped, so + # a retry usually completes; the corrective sentence below is + # surfaced to the retry worker via the prior-attempt error in + # ``build_worker_context`` (guidance approach from #61817). protocol_violation = True error_text = ( "worker exited cleanly (rc=0) without calling " - "kanban_complete or kanban_block — protocol violation" + "kanban_complete or kanban_block — protocol violation. " + "If the prior run already did the work, verify it and " + "report the result via kanban_complete; a run that ends " + "without a terminal kanban call counts as failed no " + "matter what it did." ) event_kind = "protocol_violation" event_payload = { "pid": pid, "claimer": row["claim_lock"], "exit_code": code, + # Durable marker for _protocol_violation_streak: _end_run + # copies this payload into the run metadata, which is how + # the violation-only retry budget is derived later. + "protocol_violation": True, } elif kind == "rate_limited": # Worker bailed because the provider rate-limited / exhausted @@ -6490,21 +6797,39 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]: ) rate_limited.append(row["id"]) else: + if protocol_violation: + # Stamp the failure error now: a below-budget + # violation never reaches ``_record_task_failure`` + # (which stamps this column for every other failure + # kind), yet the board UI and the retry worker's + # context still need the violation message + the + # corrective guidance it carries. + conn.execute( + "UPDATE tasks SET last_failure_error = ? " + "WHERE id = ?", + (error_text[:500], row["id"]), + ) crashed.append(row["id"]) crash_details.append( (row["id"], pid, row["claim_lock"], protocol_violation, error_text) ) - # Outside the main txn: increment the unified failure counter for - # each crashed task. If the breaker trips, the task transitions - # ready → blocked with a ``gave_up`` event on top of the ``crashed`` - # event we already emitted. + # Outside the main txn: account each crashed task and maybe trip the + # breaker (the task transitions ready → blocked with a ``gave_up`` event + # on top of the event we already emitted). # - # Protocol-violation crashes force an immediate trip (failure_limit=1) - # because clean-exit-without-transition is deterministic: the next - # respawn will do exactly the same thing. Better to surface to a - # human with a clear reason than to loop ``DEFAULT_FAILURE_LIMIT`` - # times first. + # Protocol-violation crashes (clean exit, no terminal tool call) get a + # BOUNDED retry, not an immediate trip: empirically ~96% of these tasks + # complete on a later run (a goal-mode finalize nudge, or the model simply + # emitting kanban_complete/kanban_block next time), so blocking on the first + # occurrence just churned them through the respawn cycle. The retry budget + # is a violation-only streak (``_protocol_violation_streak``): earlier + # timeouts / nonzero exits neither consume nor extend it, and a + # below-budget violation does not tick the unified + # ``consecutive_failures`` counter, so the two budgets stay independent. + # A per-task ``max_retries`` overrides the violation bound with the same + # top precedence it has for every other failure kind. Systemic same-error + # crashes still trip immediately. auto_blocked: list[str] = [] if crash_details: # Fingerprint errors to detect systemic failures. @@ -6513,16 +6838,59 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]: fp = _error_fingerprint(err_text) _fp_counts[fp] = _fp_counts.get(fp, 0) + 1 for tid, pid, claimer, protocol_violation, error_text in crash_details: + if protocol_violation: + streak = _protocol_violation_streak(conn, tid) + trow = conn.execute( + "SELECT max_retries FROM tasks WHERE id = ?", (tid,), + ).fetchone() + if trow is None: + continue # task deleted mid-loop + task_override = ( + trow["max_retries"] if "max_retries" in trow.keys() else None + ) + violation_limit = ( + int(task_override) + if task_override is not None + else _PROTOCOL_VIOLATION_FAILURE_LIMIT + ) + if streak < violation_limit: + # Below budget: the task is already back at ``ready`` + # (respawn allowed) with ``last_failure_error`` stamped. + # Deliberately no ``_record_task_failure`` call — a + # below-budget violation must not consume the unified + # failure budget, just as other failure kinds don't + # consume this one. + continue + # Streak reached the bound: trip the breaker. ``force_trip`` + # skips the threshold resolution inside + # ``_record_task_failure`` because the decision — including + # the per-task ``max_retries`` override — was already made + # against the violation streak above. + tripped = _record_task_failure( + conn, tid, + error=error_text, + outcome="crashed", + failure_limit=violation_limit, + force_trip=True, + release_claim=False, + end_run=False, + event_payload_extra={ + "pid": pid, + "claimer": claimer, + "protocol_violations": streak, + "protocol_violation_limit": violation_limit, + }, + ) + if tripped: + auto_blocked.append(tid) + continue fp = _error_fingerprint(error_text) - is_systemic = ( - not protocol_violation - and _fp_counts.get(fp, 0) >= 3 - ) + is_systemic = _fp_counts.get(fp, 0) >= 3 tripped = _record_task_failure( conn, tid, error=error_text, outcome="crashed", - failure_limit=1 if (protocol_violation or is_systemic) else None, + failure_limit=1 if is_systemic else None, release_claim=False, end_run=False, event_payload_extra={"pid": pid, "claimer": claimer}, @@ -6547,6 +6915,7 @@ def _record_task_failure( *, outcome: str, failure_limit: int = None, + force_trip: bool = False, release_claim: bool = False, end_run: bool = False, event_payload_extra: Optional[dict] = None, @@ -6584,6 +6953,15 @@ def _record_task_failure( 2. caller-supplied ``failure_limit`` (gateway passes the config value from ``kanban.failure_limit``; tests pass fixed values) 3. ``DEFAULT_FAILURE_LIMIT`` + + ``force_trip=True`` trips the breaker unconditionally, skipping the + counter-vs-threshold comparison (the resolution order above is then + only reported in the ``gave_up`` payload, not re-evaluated). Callers + use it when they have already applied their own bounded-retry policy + — e.g. the clean-exit protocol-violation streak in + ``detect_crashed_workers``, which resolves the per-task + ``max_retries`` override against the violation streak itself. The + failure is still counted into ``consecutive_failures``. """ if failure_limit is None: failure_limit = DEFAULT_FAILURE_LIMIT @@ -6610,7 +6988,7 @@ def _record_task_failure( effective_limit = int(failure_limit) limit_source = "dispatcher" - if failures >= effective_limit: + if force_trip or failures >= effective_limit: # Trip the breaker. if release_claim: # Spawn path: still running, also clear claim state. @@ -6789,7 +7167,9 @@ def check_respawn_guard(conn: sqlite3.Connection, task_id: str) -> Optional[str] ``"recent_success"`` A completed run exists within ``_RESPAWN_GUARD_SUCCESS_WINDOW`` seconds. Useful work already succeeded for this task; wait for - human review rather than immediately re-spawning. + human review rather than immediately re-spawning. Bypassed when an + explicit re-queue event (status change, promote, unblock, reclaim) + arrives AFTER that completion — that's a deliberate re-run request. ``"active_pr"`` A GitHub PR URL appears in a recent task comment (within @@ -6852,13 +7232,29 @@ def check_respawn_guard(conn: sqlite3.Connection, task_id: str) -> Optional[str] return "blocker_auth" # 3. Completed run within guard window — proof of recent success. + # Exception: an explicit re-queue AFTER that success (an operator + # dragging done→ready, a dependency re-promotion, an unblock, a + # reclaim) is a deliberate "run it again" — honor it instead of + # deferring. Without this, a manual done→ready just sits there, + # silently held by the guard, until the window elapses. cutoff = now - _RESPAWN_GUARD_SUCCESS_WINDOW - if conn.execute( - "SELECT id FROM task_runs " - "WHERE task_id = ? AND outcome = 'completed' AND ended_at >= ?", + recent_completed = conn.execute( + "SELECT ended_at FROM task_runs " + "WHERE task_id = ? AND outcome = 'completed' AND ended_at >= ? " + "ORDER BY ended_at DESC LIMIT 1", (task_id, cutoff), - ).fetchone(): - return "recent_success" + ).fetchone() + if recent_completed: + completed_at = int(recent_completed["ended_at"] or 0) + requeued_after = conn.execute( + "SELECT 1 FROM task_events " + "WHERE task_id = ? AND created_at >= ? " + "AND kind IN ('status', 'promoted', 'unblocked', 'reclaimed') " + "LIMIT 1", + (task_id, completed_at), + ).fetchone() + if not requeued_after: + return "recent_success" # 4. GitHub PR URL in a recent comment — prior worker already opened a PR. pr_cutoff = now - _RESPAWN_GUARD_PR_WINDOW @@ -7768,9 +8164,18 @@ def _default_spawn( # attributed correctly regardless of how the child loads config. env["HERMES_PROFILE"] = profile_arg + # A worker must NEVER boot the interactive TUI: an inherited HERMES_TUI=1 + # or a `display.interface: tui` in the profile's config would send the + # quiet chat run into the Ink TUI, whose no-TTY bail-out exits 0 without + # doing the task → "protocol violation" on every attempt. `--cli` is the + # highest-precedence interface override; dropping the env var covers + # older hermes builds on PATH that predate the flag's precedence. + env.pop("HERMES_TUI", None) + cmd = [ *_resolve_hermes_argv(), "-p", profile_arg, + "--cli", # Worker subprocesses switch to a profile-scoped HERMES_HOME above, # so they see that profile's shell-hook allowlist instead of the # dispatcher's root allowlist. Pass --accept-hooks explicitly so @@ -7795,6 +8200,13 @@ def _default_spawn( "chat", "-q", prompt, ]) + if task.goal_mode: + # Goal-mode workers must take the fully-quiet single-query path: + # the kanban goal-loop hook (_run_kanban_goal_loop_q) only runs in + # cli.py's quiet branch. Without -Q the worker gets exactly one + # turn, prints text, exits rc=0, and the dispatcher records a + # protocol violation (incident 2026-06-09 t_d9cbe312). + cmd.append("-Q") # Redirect output to a per-task log under <board-root>/logs/. # Anchored at the board root (not the shared kanban root), so # `hermes kanban log` on a specific board reads its own file and diff --git a/hermes_cli/kanban_decompose.py b/hermes_cli/kanban_decompose.py index 8af2da9114fe..d6c248c658a6 100644 --- a/hermes_cli/kanban_decompose.py +++ b/hermes_cli/kanban_decompose.py @@ -298,23 +298,11 @@ def decompose_task( roster, valid_names = _build_roster() try: - from agent.auxiliary_client import ( # type: ignore - get_auxiliary_extra_body, - get_text_auxiliary_client, - ) + from agent.auxiliary_client import call_llm # type: ignore except Exception as exc: logger.debug("decompose: auxiliary client import failed: %s", exc) return DecomposeOutcome(task_id, False, "auxiliary client unavailable") - try: - client, model = get_text_auxiliary_client("kanban_decomposer") - except Exception as exc: - logger.debug("decompose: get_text_auxiliary_client failed: %s", exc) - return DecomposeOutcome(task_id, False, "auxiliary client unavailable") - - if client is None or not model: - return DecomposeOutcome(task_id, False, "no auxiliary client configured") - user_msg = _USER_TEMPLATE.format( task_id=task.id, title=_truncate(task.title or "", 400), @@ -324,8 +312,12 @@ def decompose_task( ) try: - resp = client.chat.completions.create( - model=model, + # Route through call_llm so auxiliary.kanban_decomposer.* config + # (provider/model/base_url, extra_body, reasoning_effort, retries) + # all apply — the previous direct client.chat.completions.create() + # path dropped auxiliary.<task>.extra_body entirely (#35566). + resp = call_llm( + task="kanban_decomposer", messages=[ {"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user_msg}, @@ -333,7 +325,6 @@ def decompose_task( temperature=0.3, max_tokens=4000, timeout=timeout or 180, - extra_body=get_auxiliary_extra_body() or None, ) except Exception as exc: logger.info( diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index 81da4c191561..8173f1e33388 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -530,7 +530,20 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: Accepts the legacy ``spawn_failure_threshold`` config key for back-compat. + + Terminal statuses are exempt: a done/archived card has nothing left + to retry, so a lingering failure streak is history, not a signal. + (``complete_task`` resets the counter, but a manual done — e.g. a + dashboard drag — ends no run and used to leave the flag stuck.) + + A fresh attempt in flight (``running``) is also exempt: retrying a + task should clear the stale failure banner until this attempt also + resolves. Otherwise a card that's actively trying again still shows + "failed Nx", which reads as a current failure. It re-fires if the new + run fails too (status leaves ``running`` with a recorded outcome). """ + if _task_field(task, "status") in ("done", "archived", "running"): + return [] threshold = _positive_int(cfg.get( "failure_threshold", cfg.get("spawn_failure_threshold", 3), @@ -649,7 +662,20 @@ def _rule_repeated_crashes(task, events, runs, now, cfg) -> list[Diagnostic]: total failures) so the operator gets a crash-specific heads-up before the unified rule kicks in. Suppresses itself when the unified rule is also about to fire, to avoid double-flagging. + + Terminal statuses are exempt for the same reason as + ``repeated_failures`` — with one extra wrinkle: this rule reads run + history, and a manual done (dashboard drag) appends no ``completed`` + run to break the crash streak, so the flag was permanent (#kanban + desktop dogfood). Done means done. + + ``running`` is exempt too: a fresh attempt is in flight, and its + in-flight run (no outcome yet) doesn't break the trailing crash scan, + so a retried card kept showing "crashed Nx" over an active run. The + banner re-fires if the new attempt also crashes. """ + if _task_field(task, "status") in ("done", "archived", "running"): + return [] failure_threshold = int(cfg.get( "failure_threshold", cfg.get("spawn_failure_threshold", 3), diff --git a/hermes_cli/kanban_specify.py b/hermes_cli/kanban_specify.py index 40812d835f06..e7aba34a3717 100644 --- a/hermes_cli/kanban_specify.py +++ b/hermes_cli/kanban_specify.py @@ -162,22 +162,11 @@ def specify_task( ) try: - from agent.auxiliary_client import get_auxiliary_extra_body, get_text_auxiliary_client + from agent.auxiliary_client import call_llm except Exception as exc: # pragma: no cover — import smoke test logger.debug("specify: auxiliary client import failed: %s", exc) return SpecifyOutcome(task_id, False, "auxiliary client unavailable") - try: - client, model = get_text_auxiliary_client("triage_specifier") - except Exception as exc: - logger.debug("specify: get_text_auxiliary_client failed: %s", exc) - return SpecifyOutcome(task_id, False, "auxiliary client unavailable") - - if client is None or not model: - return SpecifyOutcome( - task_id, False, "no auxiliary client configured" - ) - user_msg = _USER_TEMPLATE.format( task_id=task.id, title=_truncate(task.title or "", 400), @@ -185,8 +174,11 @@ def specify_task( ) try: - resp = client.chat.completions.create( - model=model, + # Route through call_llm so auxiliary.triage_specifier.* config + # (provider/model/base_url, extra_body, reasoning_effort, retries) + # all apply — the direct-create path dropped extra_body (#35566). + resp = call_llm( + task="triage_specifier", messages=[ {"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user_msg}, @@ -194,7 +186,6 @@ def specify_task( temperature=0.3, max_tokens=HERMES_KANBAN_SPECIFY_MAX_TOKENS, timeout=timeout or 120, - extra_body=get_auxiliary_extra_body() or None, ) except Exception as exc: logger.info( diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f21388c21812..45ce0a7a8c26 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -148,7 +148,17 @@ def _wants_tui_early(argv: "list[str] | None" = None) -> bool: """Earliest TUI decision, usable before argparse/config imports. Precedence: explicit ``--cli`` wins (forces classic REPL), then - ``--tui``/``HERMES_TUI=1``, then ``display.interface`` in config. + explicit ``--tui``/``HERMES_TUI=1``, then a real-TTY gate (a + non-interactive stdio can't host the Ink UI, so ambient config never + boots it there), then ``display.interface`` in config. + + The TTY gate is load-bearing for headless spawners — kanban workers, + cron jobs, pipes run ``hermes … chat -q`` with stdio on a pipe. This + is the earliest launch decision (it runs before ``cmd_chat`` / + ``_resolve_use_tui``), so a ``display.interface: tui`` default used to + boot the TUI here — whose no-TTY bail-out exits 0 without doing the + task → "protocol violation" on every attempt. An explicit ``--tui`` + still reaches the informative bail-out. """ if argv is None: argv = sys.argv[1:] @@ -156,6 +166,11 @@ def _wants_tui_early(argv: "list[str] | None" = None) -> bool: return False if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv: return True + try: + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return False + except Exception: + return False return _config_default_interface_early() == "tui" @@ -2193,16 +2208,34 @@ def _resolve_use_tui(args) -> bool: Precedence (highest first): 1. ``--cli`` flag → always classic REPL - 2. ``--tui`` flag / ``HERMES_TUI=1`` → always TUI - 3. ``display.interface`` config value ("cli" | "tui") - 4. default → classic REPL + 2. ``--tui`` flag → always TUI (explicit ask) + 3. no TTY → always classic (ambient prefs don't apply) + 4. ``HERMES_TUI=1`` env → TUI + 5. ``display.interface`` config value ("cli" | "tui") + 6. default → classic REPL Explicit flags always win over config so muscle memory and scripts keep working regardless of the configured default. + + The TTY gate (3) is load-bearing: ambient TUI preferences (env var or + config default) must never hijack a NON-interactive invocation. Kanban + workers, cron jobs, and pipelines run ``hermes … chat -q`` with stdout + on a pipe; booting the Ink TUI there hits its no-TTY bail-out, which + prints a resume hint and exits 0 — a kanban worker then dies with + "exited cleanly without calling kanban_complete — protocol violation" + on every attempt (found dogfooding the desktop kanban board). A user + who *explicitly* passes ``--tui`` still gets the informative bail-out. """ if getattr(args, "cli", False): return False - if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1": + if getattr(args, "tui", False): + return True + try: + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return False + except Exception: + return False + if os.environ.get("HERMES_TUI") == "1": return True try: from hermes_cli.config import load_config @@ -2253,6 +2286,27 @@ def cmd_chat(args): # If resolution fails, keep the original value — _init_agent will # report "Session not found" with the original input + # Session<->workspace binding: cd back into a resumed session's recorded cwd + # so it resumes in the repo it belonged to. Opt out with --no-restore-cwd; + # skipped under --worktree (that path owns its own dir). Best-effort — a + # missing dir warns and stays put rather than failing the resume. + if ( + getattr(args, "resume", None) + and not getattr(args, "no_restore_cwd", False) + and not getattr(args, "worktree", False) + ): + try: + from hermes_state import SessionDB + + _saved_cwd = ((SessionDB().get_session(args.resume) or {}).get("cwd") or "").strip() + if _saved_cwd and not os.path.isdir(_saved_cwd): + print(f"⚠ session's recorded dir is gone ({_saved_cwd}); staying in {os.getcwd()}") + elif _saved_cwd and os.path.realpath(_saved_cwd) != os.path.realpath(os.getcwd()): + os.chdir(_saved_cwd) + print(f"↪ restored workspace dir: {_saved_cwd}") + except Exception: + pass # never let cwd-restore break a resume + # xAI retirement warning — one-shot, non-blocking, never fails startup try: from hermes_cli.xai_retirement import ( @@ -2325,7 +2379,11 @@ def cmd_chat(args): except Exception: pass - # --yolo: bypass all dangerous command approvals + # --yolo: bypass all dangerous command approvals. + # Also set in main() before _prepare_agent_startup() — that is the + # authoritative site because it runs before tool imports freeze + # _YOLO_MODE_FROZEN. This redundant set is a safety net for callers + # that invoke cmd_chat directly (e.g. subcommand dispatch). if getattr(args, "yolo", False): os.environ["HERMES_YOLO_MODE"] = "1" @@ -3888,7 +3946,7 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""): str(effort).strip().lower() for effort in efforts if str(effort).strip() ) ) - canonical_order = ("minimal", "low", "medium", "high", "xhigh") + canonical_order = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra") ordered = [effort for effort in canonical_order if effort in deduped] ordered.extend(effort for effort in deduped if effort not in canonical_order) if not ordered: @@ -8068,6 +8126,90 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None: return resolve_uv() or shutil.which("uv") +def _npm_manifest_paths() -> tuple[Path, ...]: + """Manifests whose changes must defeat the update-skip. + + The lockfile alone is NOT a sufficient key: on a local checkout a dev + can edit package.json (root or a workspace) without running npm — the + lockfile is then unchanged but `hermes update` is exactly the step + expected to sync node_modules (via the `npm install` fallback in + _run_npm_install_deterministic). + + The workspace list is pulled from the root package.json's `workspaces` + globs (npm's own source of truth) rather than hardcoded, so adding a + workspace can never silently escape the skip key. The root install + (step 1, --workspaces=false) still hoists shared deps for EVERY + workspace — desktop included — so all of them belong in the key, not + just the ones step 2 installs. Falls back to hashing just root + manifests if package.json is unreadable (never skips more than main + would have installed). + """ + root_pkg = PROJECT_ROOT / "package.json" + paths = [PROJECT_ROOT / "package-lock.json", root_pkg] + try: + workspaces = json.loads(root_pkg.read_text(encoding="utf-8")).get( + "workspaces", [] + ) + if isinstance(workspaces, dict): # legacy {"packages": [...]} form + workspaces = workspaces.get("packages", []) + for pattern in workspaces: + for match in sorted(PROJECT_ROOT.glob(str(pattern))): + manifest = match / "package.json" + if manifest.is_file(): + paths.append(manifest) + except (OSError, json.JSONDecodeError, TypeError): + pass + return tuple(paths) + + +def _npm_manifests_digest() -> str | None: + """Combined sha256 over the lockfile + all workspace package.json files. + + Returns None when the lockfile is missing (never skip then). + """ + if not (PROJECT_ROOT / "package-lock.json").exists(): + return None + h = hashlib.sha256() + for p in _npm_manifest_paths(): + h.update(str(p.relative_to(PROJECT_ROOT)).encode()) + try: + h.update(p.read_bytes()) + except OSError: + h.update(b"<missing>") + return h.hexdigest() + + +def _npm_lockfile_changed(hermes_root: Path) -> bool: + current = _npm_manifests_digest() + if current is None: + return True + # Also check that node_modules exists; a matching hash with missing + # node_modules means the cache was recorded by another checkout. + if not (PROJECT_ROOT / "node_modules").is_dir(): + return True + try: + # Key the cache by PROJECT_ROOT so parallel worktrees don't collide. + cache_key = hashlib.sha256(str(PROJECT_ROOT).encode()).hexdigest()[:12] + cache_file = hermes_root / f".npm_lock_hash_{cache_key}" + if not cache_file.exists(): + return True + return cache_file.read_text(encoding="utf-8").strip() != current + except OSError: + return True + + +def _record_npm_lockfile_hash(hermes_root: Path) -> None: + digest = _npm_manifests_digest() + if digest is None: + return + try: + cache_key = hashlib.sha256(str(PROJECT_ROOT).encode()).hexdigest()[:12] + cache_file = hermes_root / f".npm_lock_hash_{cache_key}" + cache_file.write_text(digest, encoding="utf-8") + except OSError: + logger.debug("Could not write npm lockfile hash cache") + + def _update_node_dependencies() -> None: from hermes_constants import find_node_executable, with_hermes_node_path @@ -8078,6 +8220,16 @@ def _update_node_dependencies() -> None: if not (PROJECT_ROOT / "package.json").exists(): return + from hermes_constants import get_default_hermes_root + + # This cache describes PROJECT_ROOT/node_modules, which is shared by every + # Hermes profile using this checkout. Keep one per-checkout cache under the + # shared Hermes root rather than rerunning npm once per named profile. + shared_hermes_root = get_default_hermes_root() + if not _npm_lockfile_changed(shared_hermes_root): + logger.info("npm lockfile unchanged, skipping npm install") + return + # With a single workspace lockfile the root install would cover ALL # workspaces — but apps/desktop pulls in Electron as a devDependency, # and its postinstall downloads a ~200MB binary. Most users don't @@ -8122,6 +8274,7 @@ def _update_node_dependencies() -> None: env=nixos_env, ) if ws_result.returncode == 0: + _record_npm_lockfile_hash(shared_hermes_root) print(" ✓ repo root + ui-tui, web workspaces (desktop skipped)") else: print(" ⚠ npm workspace install failed") @@ -12080,6 +12233,23 @@ def cmd_dashboard(args): print(" Or drop --skip-build to build automatically.") sys.exit(1) print(f"→ Skipping web UI build (--skip-build); using dist at {_dist_root}") + else: + # HERMES_WEB_DIST is set without --skip-build: the build is skipped + # (the env var points at a caller-managed dist), so validate it the + # same way the --skip-build branch does — otherwise the server starts + # and serves 404s with no obvious cause (same failure mode as #23817, + # via the env-var path). + _dist_root = Path(os.environ["HERMES_WEB_DIST"]).expanduser() + if not (_dist_root / "index.html").exists(): + print(f"✗ HERMES_WEB_DIST is set but no web dist found at: {_dist_root}") + print(" Pre-build first: npm install --workspace web && npm run build -w web") + print(" Or unset HERMES_WEB_DIST to build and use the default web UI dist.") + sys.exit(1) + # Write the expanded path back: web_server reads HERMES_WEB_DIST raw + # at import (no expanduser), so a validated "~/dist" would otherwise + # pass here and still 404 there. + os.environ["HERMES_WEB_DIST"] = str(_dist_root) + print(f"→ Using web dist from HERMES_WEB_DIST: {_dist_root}") # Discover and load plugins so any DashboardAuthProvider plugin # (e.g. plugins/dashboard_auth/nous) registers BEFORE start_server's @@ -12360,6 +12530,15 @@ def _should_background_mcp_startup(args) -> bool: def _prepare_agent_startup(args) -> None: """Discover plugins/MCP/hooks for commands that can run an agent turn.""" + # --yolo: chokepoint guarantee that HERMES_YOLO_MODE is set before ANY + # plugin/tool discovery below imports tools.approval, which freezes + # _YOLO_MODE_FROZEN at import time (PR #7994 security design). main()'s + # dispatch path also sets this earlier, but _prepare_agent_startup() is + # reachable from other launchers too (e.g. the Termux fast-CLI path), + # so the guarantee lives here where the import is actually triggered + # (#60328). + if getattr(args, "yolo", False): + os.environ["HERMES_YOLO_MODE"] = "1" _apply_safe_mode(args) _sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None)) @@ -13451,6 +13630,12 @@ def main(): sessions_list.add_argument( "--limit", type=int, default=20, help="Max sessions to show" ) + sessions_list.add_argument( + "--workspace", + metavar="NEEDLE", + help="Only sessions in one workspace: a git repo root or project dir " + "(matched by path substring or basename).", + ) def _add_session_filter_args(p, default_older_help): p.add_argument( @@ -13777,13 +13962,58 @@ def main(): _exclude = None if _source else ["tool"] if action == "list": + from hermes_state import workspace_key as _ws_key + sessions = db.list_sessions_rich( source=args.source, exclude_sources=_exclude, limit=args.limit ) + + # Workspace filter: match a session by its workspace key (git repo + # root, else cwd) — path substring or exact basename. + _ws_filter = (getattr(args, "workspace", None) or "").strip() + if _ws_filter: + _needle = _ws_filter.lower() + + def _in_workspace(s): + key = (_ws_key(s) or "").lower() + return bool(key) and ( + _needle in key or _needle == os.path.basename(key.rstrip("/\\")) + ) + + sessions = [s for s in sessions if _in_workspace(s)] + if not sessions: print("No sessions found.") return + + # Short workspace label: the repo/dir basename, "—" when unbound. The + # Workspace column only appears once at least one session carries one + # (or when filtering), so all-unbound listings read as before. + def _ws_label(s): + key = _ws_key(s) + return (os.path.basename(key.rstrip("/\\")) or key) if key else "—" + + has_ws = bool(_ws_filter) or any(_ws_key(s) for s in sessions) has_titles = any(s.get("title") for s in sessions) + + if has_ws: + if has_titles: + print(f"{'Title':<28} {'Workspace':<18} {'Last Active':<13} {'ID'}") + print("─" * 110) + else: + print(f"{'Preview':<38} {'Workspace':<18} {'Last Active':<13} {'Src':<6} {'ID'}") + print("─" * 100) + for s in sessions: + last_active = _relative_time(s.get("last_active")) + ws = _ws_label(s)[:16] + if has_titles: + title = (s.get("title") or "—")[:26] + print(f"{title:<28} {ws:<18} {last_active:<13} {s['id']}") + else: + preview = s.get("preview", "")[:36] + print(f"{preview:<38} {ws:<18} {last_active:<13} {s['source']:<6} {s['id']}") + return + if has_titles: print(f"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}") print("─" * 110) @@ -14577,6 +14807,15 @@ def main(): cmd_version(args) return + # --yolo: set HERMES_YOLO_MODE *before* plugin discovery. The call to + # _prepare_agent_startup() below triggers discover_plugins() → tool + # imports, and tools.approval freezes _YOLO_MODE_FROZEN at module + # import time (PR #7994, security hardening against prompt-injection). + # If the env var is set only later (e.g. inside cmd_chat), the frozen + # value is already False and --yolo silently does nothing. + if getattr(args, "yolo", False): + os.environ["HERMES_YOLO_MODE"] = "1" + # Discover Python plugins and register shell hooks once, before any # command that can fire lifecycle hooks. Both are idempotent; gated # so introspection/management commands (hermes hooks list, cron diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index aab35394964a..f2a5303abe94 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -10,7 +10,11 @@ Catalog policy: - Entries are added only by merging a PR into hermes-agent. Presence in the ``optional-mcps/`` directory = Nous approval. No community tier, no trust signals beyond "it's in the catalog". -- Manifests pin transport details (commands, args, refs). MCPs are never +- Manifests pin transport details (commands, args, refs). Pins follow the + same supply-chain rules as pyproject dependencies: exact versions for + package launchers (``uvx pkg==X``, ``npx pkg@X``), full commit SHAs for + git installs, and the pinned release should be at least 2 weeks old at + pin time. MCPs are never auto-updated; users explicitly re-run ``hermes mcp install <name>`` to pull a new manifest version after a repo update. - Secrets prompted at install time go to ``~/.hermes/.env`` (the @@ -77,6 +81,10 @@ class TransportSpec: args: List[str] = field(default_factory=list) url: Optional[str] = None version: Optional[str] = None # informational, pinned + # Static environment variables for the stdio subprocess (e.g. telemetry + # opt-outs, mode flags). NOT for secrets — credentials go through + # auth.env so they are prompted for and land in ~/.hermes/.env. + env: Dict[str, str] = field(default_factory=dict) @dataclass @@ -184,12 +192,20 @@ def _parse_manifest(path: Path) -> CatalogEntry: args = transport_raw.get("args") or [] if not isinstance(args, list): raise CatalogError(f"{path}: transport.args must be a list") + env_raw = transport_raw.get("env") or {} + if not isinstance(env_raw, dict) or not all( + isinstance(k, str) and isinstance(v, str) for k, v in env_raw.items() + ): + raise CatalogError( + f"{path}: transport.env must be a mapping of string to string" + ) transport = TransportSpec( type=t_type, command=transport_raw.get("command"), args=[str(a) for a in args], url=transport_raw.get("url"), version=transport_raw.get("version"), + env=dict(env_raw), ) if t_type == "stdio" and not transport.command: raise CatalogError(f"{path}: stdio transport requires 'command'") @@ -468,6 +484,8 @@ def _build_server_config( cfg["command"] = _expand_install_dir(t.command or "", install_dir) if t.args: cfg["args"] = [_expand_install_dir(a, install_dir) for a in t.args] + if t.env: + cfg["env"] = dict(t.env) elif t.type == "http": cfg["url"] = t.url if entry.auth.type == "oauth": diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 304cfcc61b23..76dcc7e7e9c1 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -171,6 +171,31 @@ def _strip_bearer_prefix(token: str) -> str: return stripped +def _bearer_auth_headers(name: str) -> Dict[str, str]: + """Build the persisted Authorization header for a named MCP server. + + The secret itself lives in the active profile's ``.env`` file. Keeping + this template construction beside ``_env_key_for_server`` ensures the CLI + and Dashboard produce byte-equivalent MCP configuration. + """ + env_key = _env_key_for_server(name) + return {"Authorization": f"Bearer ${{{env_key}}}"} + + +def _save_bearer_auth_token(name: str, token: str) -> Dict[str, str]: + """Persist a Bearer token in the active profile and return safe headers. + + ``token`` is a one-time provisioning value. It is normalized and written + only to ``.env``; callers persist the returned interpolation template in + ``config.yaml``. + """ + normalized = _strip_bearer_prefix(token) + if not normalized or normalized.lower() == "bearer": + raise ValueError("Bearer token is required") + save_env_value(_env_key_for_server(name), normalized) + return _bearer_auth_headers(name) + + def _parse_env_assignments(raw_env: Optional[List[str]]) -> Dict[str, str]: """Parse ``KEY=VALUE`` strings from CLI args into an env dict.""" parsed: Dict[str, str] = {} @@ -492,19 +517,17 @@ def cmd_mcp_add(args): existing_key = get_env_value(env_key) if existing_key: _success(f"{env_key}: already configured") - api_key = existing_key else: api_key = _prompt("API key / Bearer token", password=True) if api_key: - api_key = _strip_bearer_prefix(api_key) - save_env_value(env_key, api_key) + server_config["headers"] = _save_bearer_auth_token( + name, api_key + ) _success(f"Saved to {display_hermes_home()}/.env as {env_key}") # Set header with env var interpolation - if api_key or existing_key: - server_config["headers"] = { - "Authorization": f"Bearer ${{{env_key}}}" - } + if existing_key: + server_config["headers"] = _bearer_auth_headers(name) # ── Discovery: connect and list tools ───────────────────────────── diff --git a/hermes_cli/moa_cmd.py b/hermes_cli/moa_cmd.py index 938d6baeacbf..417ce23a11cd 100644 --- a/hermes_cli/moa_cmd.py +++ b/hermes_cli/moa_cmd.py @@ -60,6 +60,12 @@ def _pick_slot(current: dict[str, str] | None = None) -> dict[str, str]: return {"provider": str(provider.get("slug") or ""), "model": str(model)} +def _format_slot(slot: dict[str, Any]) -> str: + label = f"{slot['provider']}:{slot['model']}" + effort = str(slot.get("reasoning_effort") or "").strip() + return f"{label} [reasoning={effort}]" if effort else label + + def _print_config(config: dict[str, Any]) -> None: cfg = normalize_moa_config(config.get("moa") if isinstance(config, dict) else {}) print("Mixture of Agents presets") @@ -71,9 +77,9 @@ def _print_config(config: dict[str, Any]) -> None: print(f"\n{marker} {name}") print(" Reference models:") for idx, slot in enumerate(preset["reference_models"], start=1): - print(f" {idx}. {slot['provider']}:{slot['model']}") + print(f" {idx}. {_format_slot(slot)}") agg = preset["aggregator"] - print(f" Aggregator: {agg['provider']}:{agg['model']}") + print(f" Aggregator: {_format_slot(agg)}") def cmd_moa(args) -> None: diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py index af1241418bed..e3fc77f7439d 100644 --- a/hermes_cli/moa_config.py +++ b/hermes_cli/moa_config.py @@ -73,7 +73,23 @@ def _coerce_fanout(value: Any) -> str: return mode if mode in {"per_iteration", "user_turn"} else "per_iteration" -def _clean_slot(slot: Any) -> dict[str, str] | None: +def _clean_reasoning_effort(value: Any) -> str | None: + """Return a canonical per-slot reasoning effort, or None when unset/invalid.""" + if value is None or value is True: + return None + if value is False: + return "none" + text = str(value or "").strip().lower() + if not text: + return None + if text in {"none", "false", "disabled"}: + return "none" + if text in {"minimal", "low", "medium", "high", "xhigh", "max"}: + return text + return None + + +def _clean_slot(slot: Any) -> dict[str, Any] | None: if not isinstance(slot, dict): return None provider = str(slot.get("provider") or "").strip() @@ -87,7 +103,82 @@ def _clean_slot(slot: Any) -> dict[str, str] | None: # an invalid slot is dropped, falling back to the preset's defaults. if provider.lower() == "moa": return None - return {"provider": provider, "model": model} + clean: dict[str, Any] = {"provider": provider, "model": model} + effort = _clean_reasoning_effort(slot.get("reasoning_effort")) + if effort: + clean["reasoning_effort"] = effort + return clean + + +def _slot_problem(slot: Any) -> str | None: + """Return a human-readable problem for a slot ``_clean_slot`` would drop. + + None means the slot is complete and valid. Mirrors ``_clean_slot`` exactly + so the write-boundary validator (``validate_moa_payload``) and the + tolerant runtime normalizer can never disagree about what is acceptable. + """ + if not isinstance(slot, dict): + return "must be an object with 'provider' and 'model'" + provider = str(slot.get("provider") or "").strip() + model = str(slot.get("model") or "").strip() + if not provider and not model: + return "provider and model are required" + if not provider: + return "provider is required" + if not model: + return f"model is required (provider '{provider}' has no model selected)" + if provider.lower() == "moa": + return "the Mixture of Agents provider cannot be used inside a preset (recursive MoA)" + return None + + +def validate_moa_payload(raw: Any) -> list[str]: + """Return the problems ``normalize_moa_config`` would silently paper over. + + ``normalize_moa_config`` is deliberately tolerant: at *read* time a + hand-edited config must degrade to defaults rather than crash the agent. + That same tolerance at *write* time is a corruption engine — a client that + sends a half-filled slot gets its whole preset silently replaced with the + hardcoded defaults (#64156). API write paths call this first and reject + invalid payloads loudly instead of saving something the user never chose. + + Returns a list of human-readable problems; empty means safe to save. + """ + if not isinstance(raw, dict): + return ["MoA config must be an object"] + + presets_raw = raw.get("presets") + if isinstance(presets_raw, dict) and presets_raw: + presets: dict[Any, Any] = presets_raw + else: + # Legacy flat payload: the top-level object is the default preset. + presets = {DEFAULT_MOA_PRESET_NAME: raw} + + problems: list[str] = [] + for name, preset in presets.items(): + label = str(name or "").strip() or "(unnamed)" + if not isinstance(preset, dict): + problems.append(f"preset '{label}': must be an object") + continue + + refs = preset.get("reference_models") + if not isinstance(refs, list): + refs = [refs] if isinstance(refs, dict) else [] + complete_refs = 0 + for index, slot in enumerate(refs): + issue = _slot_problem(slot) + if issue: + problems.append(f"preset '{label}' reference {index + 1}: {issue}") + else: + complete_refs += 1 + if not complete_refs: + problems.append(f"preset '{label}': needs at least one complete reference model") + + agg_issue = _slot_problem(preset.get("aggregator")) + if agg_issue: + problems.append(f"preset '{label}' aggregator: {agg_issue}") + + return problems def _default_preset() -> dict[str, Any]: diff --git a/hermes_cli/model_catalog.py b/hermes_cli/model_catalog.py index 40a3a5c00bd5..fec73234aa45 100644 --- a/hermes_cli/model_catalog.py +++ b/hermes_cli/model_catalog.py @@ -356,6 +356,42 @@ def get_curated_nous_models() -> list[str] | None: return out or None +def _default_model_from_block(block: dict[str, Any] | None) -> str | None: + """Return the id of the model entry labeled ``"default": true``, or None.""" + if not isinstance(block, dict): + return None + for m in block.get("models", []): + if isinstance(m, dict) and m.get("default"): + mid = str(m.get("id") or "").strip() + if mid: + return mid + return None + + +def get_default_model_from_cache(provider: str) -> str | None: + """Return the catalog's labeled default model for ``provider`` — cache only. + + The manifest marks exactly one model entry per provider with + ``"default": true``; that entry is the model Hermes silently lands on when + the user never picked one. This accessor reads ONLY the in-process copy or + the disk cache — it NEVER triggers a network fetch, so it is safe on hot + resolution paths (agent build, gateway session setup) that must stay + network-free. The cache is kept fresh by the picker/`hermes update` paths; + when no cached manifest exists (fresh install, offline), returns None and + the caller falls back to the in-repo constant. + """ + if _catalog_cache is not None: + block = _catalog_cache.get("providers", {}).get(provider) + found = _default_model_from_block(block) + if found: + return found + disk_data, _mtime = _read_disk_cache() + if disk_data is not None: + block = disk_data.get("providers", {}).get(provider) + return _default_model_from_block(block) + return None + + def seed_cache_from_checkout(project_root: "Path | str") -> bool: """Overwrite the disk cache with the catalog shipped in a local checkout. diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index 9bad641b1f3b..fd7029f56dfe 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -27,6 +27,54 @@ import subprocess from hermes_cli.config import clear_model_endpoint_credentials +# AWS cross-region inference profile prefixes. Any geo-prefixed profile only +# routes from endpoints in its own geography, so the Bedrock picker must not +# offer (e.g.) us.* profiles to an eu-central-2 endpoint — selecting one +# produces a config AWS rejects regardless of credentials (#28156). +# global.* routes from everywhere. Full set per the AWS cross-region +# inference docs. +BEDROCK_GEO_PREFIXES = ( + "us.", "eu.", "ap.", "apac.", "jp.", "ca.", "sa.", "me.", "af.", +) + + +def bedrock_region_geo_prefix(region_name: str) -> str: + """Map an AWS region name to its inference-profile geo prefix ('' = unknown).""" + r = (region_name or "").lower() + for geo, region_prefixes in ( + ("us.", ("us-", "us_gov")), + ("eu.", ("eu-",)), + ("ap.", ("ap-",)), + ("ca.", ("ca-",)), + ("sa.", ("sa-",)), + ("me.", ("me-",)), + ("af.", ("af-",)), + ): + if r.startswith(region_prefixes): + return geo + return "" + + +def bedrock_model_routable_from_region(model_id: str, region_name: str) -> bool: + """True when *model_id* can be invoked from *region_name*'s endpoint. + + Bare foundation-model ids and ``global.*`` profiles route from anywhere. + Geo-prefixed inference profiles (``us.*``, ``eu.*``, ...) only route from + endpoints in their own geography. Unknown region shapes hide nothing. + """ + mid = (model_id or "").lower() + matched_geo = next((p for p in BEDROCK_GEO_PREFIXES if mid.startswith(p)), None) + if matched_geo is None or mid.startswith("global."): + return True + geo = bedrock_region_geo_prefix(region_name) + if not geo: + return True + if geo == "ap.": + # Asia-Pacific regions can carry ap./apac./jp. profile spellings. + return matched_geo in ("ap.", "apac.", "jp.") + return matched_geo == geo + + def _prune_replaced_custom_model_config_credentials( base_url: str, *, @@ -2265,6 +2313,8 @@ def _model_flow_bedrock(config, current_model=""): "global.twelvelabs.", ) _EXCLUDE_SUBSTRINGS = ("safeguard", "voxtral", "palmyra-vision") + + filtered = [] for m in live_models: mid = m["id"] @@ -2272,44 +2322,59 @@ def _model_flow_bedrock(config, current_model=""): continue if any(s in mid.lower() for s in _EXCLUDE_SUBSTRINGS): continue + if not bedrock_model_routable_from_region(mid, region): + continue filtered.append(m) - # Deduplicate: prefer inference profiles (us.*, global.*) over bare - # foundation model IDs. + # Deduplicate: prefer inference profiles (geo-prefixed or global.*) + # over bare foundation model IDs. + _PROFILE_PREFIXES = BEDROCK_GEO_PREFIXES + ("global.",) profile_base_ids = set() for m in filtered: mid = m["id"] - if mid.startswith(("us.", "global.")): - base = mid.split(".", 1)[1] if "." in mid[3:] else mid - profile_base_ids.add(base) + _pp = next((p for p in _PROFILE_PREFIXES if mid.startswith(p)), None) + if _pp: + profile_base_ids.add(mid[len(_pp):]) deduped = [] for m in filtered: mid = m["id"] - if not mid.startswith(("us.", "global.")) and mid in profile_base_ids: + if ( + not mid.startswith(_PROFILE_PREFIXES) + and mid in profile_base_ids + ): continue deduped.append(m) - _RECOMMENDED = [ - "us.anthropic.claude-sonnet-4-6", - "us.anthropic.claude-opus-4-6", - "us.anthropic.claude-haiku-4-5", - "us.amazon.nova-pro", - "us.amazon.nova-lite", - "us.amazon.nova-micro", + # Recommended models, matched geo-agnostically so an EU (eu.*) or + # APAC (apac.*) picker pins its own region's profile of the same + # model rather than a us.* one it can't route to (#28156). + _RECOMMENDED_BASES = [ + "anthropic.claude-sonnet-4-6", + "anthropic.claude-opus-4-6", + "anthropic.claude-haiku-4-5", + "amazon.nova-pro", + "amazon.nova-lite", + "amazon.nova-micro", "deepseek.v3", - "us.meta.llama4-maverick", - "us.meta.llama4-scout", + "meta.llama4-maverick", + "meta.llama4-scout", ] + def _base_id(mid: str) -> str: + _pp = next((p for p in _PROFILE_PREFIXES if mid.startswith(p)), None) + return mid[len(_pp):] if _pp else mid + def _sort_key(m): mid = m["id"] - for i, rec in enumerate(_RECOMMENDED): - if mid.startswith(rec): - return (0, i, mid) + base = _base_id(mid) + for i, rec in enumerate(_RECOMMENDED_BASES): + if base.startswith(rec): + # In-region geo profile beats global.* for the same model + return (0, i, 0 if not mid.startswith("global.") else 1, mid) if mid.startswith("global."): - return (1, 0, mid) - return (2, 0, mid) + return (1, 0, 0, mid) + return (2, 0, 0, mid) deduped.sort(key=_sort_key) model_list = [m["id"] for m in deduped] diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 5d9ecfd20508..fb9a348c8149 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -548,7 +548,12 @@ def _model_sort_key(model_id: str, prefix: str) -> tuple: # Suffix quality ranking: pro/max > (no suffix) > omni/flash/mini/lite # Lower number = preferred - _SUFFIX_RANK = {"pro": 0, "max": 0, "plus": 0, "turbo": 0} + # "sol" is the flagship tier of the GPT-5.6 series (sol > terra > luna); + # without it, alias resolution would tiebreak alphabetically and pick + # luna (the cheapest) for `/model gpt`. Unlike pro/max/plus/turbo it is a + # series codename, not a generic quality word — revisit if another vendor + # ever ships a "-sol" suffix that isn't a flagship. + _SUFFIX_RANK = {"pro": 0, "max": 0, "plus": 0, "turbo": 0, "sol": 0} suffix_rank = _SUFFIX_RANK.get(suffix, 1) return version_key + (suffix_rank, suffix) @@ -1389,6 +1394,25 @@ import threading as _threading # noqa: E402 _picker_prewarm_done = _threading.Event() +def _credential_pool_is_usable(provider: str, *, raw_pool_present: bool = False) -> bool: + """Return whether *provider* has a credential that can be selected now. + + ``auth.json`` historically allowed opaque token-style pool values that do + not deserialize into ``PooledCredential`` entries. Preserve visibility for + those legacy values, but when a real pool exists its availability state is + authoritative: an all-exhausted/dead pool is not authenticated. + """ + try: + from agent.credential_pool import load_pool + + pool = load_pool(provider) + if pool.has_credentials(): + return pool.has_available() + except Exception: + pass + return raw_pool_present + + def _extra_headers_from_config(entry: Any) -> dict[str, str]: if not isinstance(entry, dict): return {} @@ -1678,6 +1702,12 @@ def list_authenticated_providers( # section 2 (HERMES_OVERLAYS) with proper auth store checking. if pconfig and pconfig.auth_type != "api_key": continue + # models.dev catalogs include providers Hermes may not route yet. + # Gate on runtime capability rather than registry membership: special + # providers and plugin aliases can be routable without a registry row. + from hermes_cli.auth import is_runtime_provider_routable + if not is_runtime_provider_routable(hermes_id): + continue if pconfig and pconfig.api_key_env_vars: env_vars = list(pconfig.api_key_env_vars) else: @@ -1691,8 +1721,13 @@ def list_authenticated_providers( try: from hermes_cli.auth import _load_auth_store store = _load_auth_store() - if store and store.get("credential_pool", {}).get(hermes_id): - has_creds = True + raw_pool_present = bool( + store and store.get("credential_pool", {}).get(hermes_id) + ) + if raw_pool_present: + has_creds = _credential_pool_is_usable( + hermes_id, raw_pool_present=True + ) except Exception: pass if not has_creds: @@ -1707,6 +1742,15 @@ def list_authenticated_providers( model_ids = curated.get(hermes_id, []) if hermes_id in _MODELS_DEV_PREFERRED: model_ids = _merge_with_models_dev(hermes_id, model_ids) + # A providers.<built-in>.models block extends the provider's discovered + # catalog. Section 3 cannot emit it later because this built-in row owns + # the slug, so merge declarations here before applying max_models. + configured_models: list[str] = [] + if isinstance(user_providers, dict): + configured = user_providers.get(hermes_id) + if isinstance(configured, dict): + configured_models = _declared_model_ids(configured.get("models")) + model_ids = list(dict.fromkeys([*configured_models, *model_ids])) total = len(model_ids) if hermes_id in _UNCAPPED_PICKER_PROVIDERS: top = model_ids # Aggregator: show full catalog regardless of max_models @@ -1781,9 +1825,7 @@ def list_authenticated_providers( # imports on demand but aren't in the raw auth.json yet. if not has_creds: try: - from agent.credential_pool import load_pool - pool = load_pool(hermes_slug) - if pool.has_credentials(): + if _credential_pool_is_usable(hermes_slug): has_creds = True except Exception as exc: logger.debug("Credential pool check failed for %s: %s", hermes_slug, exc) @@ -1922,9 +1964,7 @@ def list_authenticated_providers( pass if not _cp_has_creds: try: - from agent.credential_pool import load_pool - _cp_pool = load_pool(_cp.slug) - if _cp_pool.has_credentials(): + if _credential_pool_is_usable(_cp.slug): _cp_has_creds = True except Exception: pass @@ -2208,6 +2248,7 @@ def list_authenticated_providers( "api_url": api_url, "api_key": api_key, "models": [], + "has_explicit_models": False, "discover_models": discover, "extra_headers": entry_extra_headers, } @@ -2230,7 +2271,10 @@ def list_authenticated_providers( if default_model and default_model not in groups[group_key]["models"]: groups[group_key]["models"].append(default_model) - for model_id in _declared_model_ids(entry.get("models", {})): + declared_models = _declared_model_ids(entry.get("models", {})) + if declared_models: + groups[group_key]["has_explicit_models"] = True + for model_id in declared_models: if model_id not in groups[group_key]["models"]: groups[group_key]["models"].append(model_id) @@ -2293,11 +2337,13 @@ def list_authenticated_providers( # the (possibly partial) ``models:`` subset configured for # context-length overrides with the full live catalog. # This is the Bifrost / aggregator-gateway case. - # - Without an api_key but with an explicit ``models:`` list - # (or top-level ``model:``), the user is narrowing a public - # endpoint to a specific subset (e.g. ollama.com /v1/models - # returns 35 models but the user only wants 4). Preserve the - # explicit list and skip live discovery. + # - Without an api_key but with an explicit ``models:`` list, + # the user is narrowing a public endpoint to a specific subset + # (e.g. ollama.com /v1/models returns 35 models but the user + # only wants 4). Preserve the explicit list and skip live + # discovery. The singular ``model:`` field is only the current + # active selection and must not suppress discovery on local + # no-key endpoints. # - Without an api_key AND no explicit models, fall through to # live discovery so bare-endpoint custom providers (local # llama.cpp / Ollama servers) still appear populated. @@ -2315,7 +2361,7 @@ def list_authenticated_providers( should_probe = ( _can_probe_custom_provider(row_is_current=_grp_is_current) and bool(api_url) - and (bool(api_key) or not grp["models"]) + and (bool(api_key) or not grp.get("has_explicit_models")) and grp.get("discover_models", True) ) if should_probe: diff --git a/hermes_cli/models.py b/hermes_cli/models.py index e19b99d4b7ca..d9235a6d43e6 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -9,6 +9,7 @@ from __future__ import annotations import json import os +import re import urllib.parse import urllib.request import urllib.error @@ -18,6 +19,7 @@ from pathlib import Path from typing import Any, NamedTuple, Optional from hermes_cli import __version__ as _HERMES_VERSION +from hermes_cli.urllib_security import open_credentialed_url # Identify ourselves so endpoints fronted by Cloudflare's Browser Integrity # Check (error 1010) don't reject the default ``Python-urllib/*`` signature. @@ -29,6 +31,10 @@ COPILOT_EDITOR_VERSION = "vscode/1.104.1" COPILOT_REASONING_EFFORTS_GPT5 = ["minimal", "low", "medium", "high"] COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"] +def _urlopen_model_catalog_request(req: urllib.request.Request, *, timeout: float): + """Open catalog requests without forwarding headers across origins.""" + return open_credentialed_url(req, timeout=timeout) + # Fallback OpenRouter snapshot used when the live catalog is unavailable. # (model_id, display description shown in menus) @@ -40,6 +46,12 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("anthropic/claude-sonnet-5", ""), ("anthropic/claude-haiku-4.5", ""), # OpenAI + ("openai/gpt-5.6-sol", ""), + ("openai/gpt-5.6-sol-pro", ""), + ("openai/gpt-5.6-terra", ""), + ("openai/gpt-5.6-terra-pro", ""), + ("openai/gpt-5.6-luna", ""), + ("openai/gpt-5.6-luna-pro", ""), ("openai/gpt-5.5", ""), ("openai/gpt-5.5-pro", ""), ("openai/gpt-5.4-mini", ""), @@ -48,7 +60,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("google/gemini-3.1-pro-preview", ""), ("google/gemini-3.5-flash", ""), # xAI - ("x-ai/grok-4.3", ""), + ("x-ai/grok-4.5", ""), # DeepSeek ("deepseek/deepseek-v4-pro", ""), ("deepseek/deepseek-v4-flash", ""), @@ -62,7 +74,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ # MiniMax ("minimax/minimax-m3", ""), # Z-AI - ("z-ai/glm-5.2", ""), + ("z-ai/glm-5.2", "default"), ("z-ai/glm-5.1", ""), # Xiaomi ("xiaomi/mimo-v2.5-pro", ""), @@ -113,6 +125,7 @@ def _codex_curated_models() -> list[str]: # grok-4-1-fast{,-reasoning,-non-reasoning}, grok-code-fast-1 → grok-4.3). _XAI_STATIC_FALLBACK: list[str] = [ "grok-build-0.1", + "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", @@ -121,6 +134,7 @@ _XAI_STATIC_FALLBACK: list[str] = [ # Callable via xAI OAuth but omitted from models.dev and /v1/models listings. _XAI_CURATED_EXTRAS: list[str] = [ + "grok-4.5", # GA 2026-07 — kept until the models.dev disk cache refreshes "grok-composer-2.5-fast", ] @@ -183,6 +197,12 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "anthropic/claude-sonnet-5", "anthropic/claude-haiku-4.5", # OpenAI + "openai/gpt-5.6-sol", + "openai/gpt-5.6-sol-pro", + "openai/gpt-5.6-terra", + "openai/gpt-5.6-terra-pro", + "openai/gpt-5.6-luna", + "openai/gpt-5.6-luna-pro", "openai/gpt-5.5", "openai/gpt-5.5-pro", "openai/gpt-5.4-mini", @@ -191,7 +211,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "google/gemini-3.1-pro-preview", "google/gemini-3.5-flash", # xAI - "x-ai/grok-4.3", + "x-ai/grok-4.5", # DeepSeek "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", @@ -231,6 +251,12 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "gpt-4o-mini", ], "openai-api": [ + "gpt-5.6-sol", + "gpt-5.6-sol-pro", + "gpt-5.6-terra", + "gpt-5.6-terra-pro", + "gpt-5.6-luna", + "gpt-5.6-luna-pro", "gpt-5.5", "gpt-5.5-pro", "gpt-5.4", @@ -417,7 +443,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "minimax-m3", "minimax-m2.7", "minimax-m2.5", - "minimax-m3-free", "glm-5.2", "glm-5.1", "glm-5", @@ -427,7 +452,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "deepseek-v4-flash-free", "qwen3.7-plus", "qwen3.6-plus", - "qwen3.6-plus-free", "qwen3.5-plus", "grok-build-0.1", "big-pickle", @@ -903,7 +927,7 @@ def fetch_nous_recommended_models( url, headers={"Accept": "application/json"}, ) - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) if not isinstance(data, dict): data = {} @@ -1057,6 +1081,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("ollama-cloud", "Ollama Cloud", "Ollama Cloud (Cloud-hosted open models, ollama.com)"), ProviderEntry("arcee", "Arcee AI", "Arcee AI (Trinity models, direct API)"), ProviderEntry("gmi", "GMI Cloud", "GMI Cloud (Multi-model direct API)"), + ProviderEntry("fireworks", "Fireworks AI", "Fireworks AI (OpenAI-compatible direct model API)"), ProviderEntry("kilocode", "Kilo Code", "Kilo Code (Kilo Gateway API)"), ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (Curated models, pay-as-you-go)"), ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (Open models subscription)"), @@ -1220,6 +1245,8 @@ _PROVIDER_ALIASES = { "arceeai": "arcee", "gmi-cloud": "gmi", "gmicloud": "gmi", + "fireworks-ai": "fireworks", + "fw": "fireworks", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", "minimax-portal": "minimax-oauth", @@ -1274,25 +1301,72 @@ _PROVIDER_ALIASES = { } -# Cost-safe overrides for the *silent* auto-default -# (``get_default_model_for_provider``). Most providers' curated lists lead with a -# sensible default, but Nous Portal is a per-token *metered aggregator* whose -# list is ordered best-/most-capable-first — entry [0] is the priciest flagship -# (``anthropic/claude-opus-4.8``, $5/$25 per Mtok). Using that as the -# non-interactive fallback when a profile sets ``provider: nous`` with no model -# silently bills the most expensive model for traffic the user never opted into -# (a missing default escalated to Opus and billed 863 requests before the user -# noticed). Pin the silent default to a low-cost curated model instead so a -# missing model can never escalate to the flagship. +# In-repo fallback for the model Hermes silently lands on when the user never +# picked one (GUI onboarding confirm card, empty ``model.default``, +# provider-set-but-model-missing resolution). The AUTHORITATIVE source is the +# remote model catalog: the manifest labels exactly one entry per provider +# with ``"default": true`` (see get_default_model_from_cache in +# model_catalog.py), so maintainers can rotate the default without shipping a +# release. This constant is the offline/fresh-install fallback and MUST match +# the labeled entry in website/static/api/model-catalog.json. Deliberately a +# capable low-cost model rather than the curated lists' entry [0]: aggregator +# lists are ordered most-capable-first, so [0] is the priciest Anthropic +# flagship (claude-fable-5 / opus) — silently billing the most expensive model +# for traffic the user never opted into. +PREFERRED_SILENT_DEFAULT_MODEL = "z-ai/glm-5.2" + + +def get_preferred_silent_default_model(provider: str = "openrouter") -> str: + """Return the silent-default model id — catalog label first, constant second. + + Reads the ``"default": true`` label from the cached remote catalog + (never hits the network — safe on hot resolution paths), falling back to + :data:`PREFERRED_SILENT_DEFAULT_MODEL` when no cached manifest exists or + the provider block carries no label. + """ + try: + from hermes_cli.model_catalog import get_default_model_from_cache + labeled = get_default_model_from_cache(provider) + if labeled: + return labeled + except Exception: + pass + return PREFERRED_SILENT_DEFAULT_MODEL + + +def pick_silent_default_model(model_ids: list[str], provider: str = "openrouter") -> str: + """Pick the silent default from an available-models list. + + Returns the catalog-labeled default (see + :func:`get_preferred_silent_default_model`) when the list carries it, + else the first entry, else "". Used by every surface that must choose a + model on the user's behalf without an interactive picker (GUI onboarding + recommended-default, empty-model runtime fallback). + """ + preferred = get_preferred_silent_default_model(provider) + if preferred in model_ids: + return preferred + return model_ids[0] if model_ids else "" + + +# Providers whose *silent* auto-default must go through the cost-safe +# catalog-labeled default (``get_preferred_silent_default_model``) instead of +# curated-list entry [0]. Metered aggregators (Nous Portal, OpenRouter) order +# their lists best-/most-capable-first — entry [0] is the priciest flagship +# (``anthropic/claude-fable-5``). Using that as the non-interactive fallback +# when a profile sets a provider with no model silently bills the most +# expensive model for traffic the user never opted into (a missing default +# escalated to Opus and billed 863 requests before the user noticed). The +# catalog manifest labels the default entry (``"default": true``) so it can +# rotate without a release; a missing model must never escalate to the +# flagship. # -# This is deliberately a fixed, side-effect-free default for the hot resolution -# path. The *interactive* default (GUI onboarding / ``hermes model``) uses the -# richer free/paid-tier-aware resolver — see ``get_recommended_default_model`` -# in hermes_cli/web_server.py and ``partition_nous_models_by_tier`` — which can -# hit the Portal; this fallback must stay cheap and network-free. -_PROVIDER_SILENT_DEFAULT_OVERRIDES: dict[str, str] = { - "nous": "deepseek/deepseek-v4-flash", -} +# This is deliberately a network-free lookup for the hot resolution path +# (cache-only catalog read). The *interactive* default (GUI onboarding / +# ``hermes model``) uses the richer free/paid-tier-aware resolver — see +# ``get_recommended_default_model`` in hermes_cli/web_server.py and +# ``partition_nous_models_by_tier`` — which can hit the Portal. +_SILENT_DEFAULT_PROVIDERS: frozenset[str] = frozenset({"nous", "openrouter"}) def get_default_model_for_provider(provider: str) -> str: @@ -1305,15 +1379,19 @@ def get_default_model_for_provider(provider: str) -> str: For most providers this is the first entry in ``_PROVIDER_MODELS`` — the same model the ``hermes model`` picker offers first. For metered aggregators whose curated list is ordered most-capable-first, that entry is also the - most EXPENSIVE one, so silently defaulting to it is a billing footgun. Such - providers carry an explicit low-cost override in - ``_PROVIDER_SILENT_DEFAULT_OVERRIDES``; a missing model must never - auto-escalate to the flagship. + most EXPENSIVE one, so silently defaulting to it is a billing footgun. + Those providers (``_SILENT_DEFAULT_PROVIDERS``) resolve through the + catalog-labeled default instead; a missing model must never auto-escalate + to the flagship. """ models = _PROVIDER_MODELS.get(provider, []) - override = _PROVIDER_SILENT_DEFAULT_OVERRIDES.get(provider) - if override and override in models: - return override + if provider in _SILENT_DEFAULT_PROVIDERS: + preferred = get_preferred_silent_default_model(provider) + # Trust the preferred default even when the provider has no static + # catalog (OpenRouter's picker list is fetched live; its curated + # snapshot carries the default). + if preferred and (preferred in models or not models): + return preferred return models[0] if models else "" @@ -1380,7 +1458,7 @@ def fetch_openrouter_models( "https://openrouter.ai/api/v1/models", headers={"Accept": "application/json"}, ) - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: payload = json.loads(resp.read().decode()) except Exception: return list(_openrouter_catalog_cache or fallback) @@ -1399,6 +1477,7 @@ def fetch_openrouter_models( live_by_id[mid] = item curated: list[tuple[str, str]] = [] + silent_default = get_preferred_silent_default_model("openrouter") for preferred_id in preferred_ids: live_item = live_by_id.get(preferred_id) if live_item is None: @@ -1408,14 +1487,20 @@ def fetch_openrouter_models( # when the user selects them. Ported from Kilo-Org/kilocode#9068. if not _openrouter_model_supports_tools(live_item): continue - desc = "free" if _openrouter_model_is_free(live_item.get("pricing")) else "" + if preferred_id == silent_default: + # Keep the silent-default badge through the live refresh so the + # picker shows which model Hermes lands on when none is selected. + desc = "default" + else: + desc = "free" if _openrouter_model_is_free(live_item.get("pricing")) else "" curated.append((preferred_id, desc)) if not curated: return list(_openrouter_catalog_cache or fallback) - first_id, _ = curated[0] - curated[0] = (first_id, "recommended") + first_id, first_desc = curated[0] + if not first_desc: + curated[0] = (first_id, "recommended") _openrouter_catalog_cache = curated return list(curated) @@ -1501,7 +1586,7 @@ def fetch_models_with_pricing( try: req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: payload = json.loads(resp.read().decode()) except Exception: _pricing_cache[cache_key] = {} @@ -1567,6 +1652,8 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d ) if normalized == "novita": return _fetch_novita_pricing(force_refresh=force_refresh) + if normalized == "deepinfra": + return _fetch_deepinfra_pricing(force_refresh=force_refresh) if normalized == "nous": api_key, base_url = _resolve_nous_pricing_credentials() if base_url: @@ -1615,7 +1702,7 @@ def _fetch_novita_pricing( try: req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: payload = json.loads(resp.read().decode()) except Exception: _pricing_cache[cache_key] = {} @@ -1917,7 +2004,18 @@ def detect_static_provider_for_model( and default_models and resolved_provider not in current_keys ): - return (resolved_provider, default_models[0]) + # Route through the cost-safe default rather than picking + # ``default_models[0]`` directly. For metered aggregators whose + # curated list is ordered most-capable-first (e.g. Nous Portal), + # entry [0] is the priciest flagship, and typing ``/model nous`` + # would silently escalate to it — the exact billing footgun the + # catalog-labeled silent default (``_SILENT_DEFAULT_PROVIDERS``) + # exists to prevent. For providers outside that set this is + # unchanged (it returns ``models[0]``). + return ( + resolved_provider, + get_default_model_for_provider(resolved_provider) or default_models[0], + ) # Aggregators list other providers' models — never auto-switch TO them # If the model belongs to the current provider's catalog, don't suggest switching @@ -2359,6 +2457,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) merged_lower.add(m.lower()) return merged return list(_PROVIDER_MODELS.get("anthropic", [])) + if normalized == "deepinfra": + # DeepInfra's generic /models endpoint mixes chat, image, video, + # speech, and embedding models. The tagged catalog helper is the only + # safe source for the chat picker, including its empty/failure result. + return _fetch_deepinfra_models(force_refresh=force_refresh) or [] if normalized == "ollama-cloud": live = fetch_ollama_cloud_models(force_refresh=force_refresh) if live: @@ -2468,7 +2571,15 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) # live API is the authoritative catalog, so they merge # live-first — live entries lead and stale curated entries # no longer pollute the top of the picker. (#49129) - curated = list(_PROVIDER_MODELS.get(normalized, [])) + # + # Plugin providers with no static _PROVIDER_MODELS entry fall + # back to the profile's curated fallback_models so their + # agentic picks lead the picker instead of whatever the live + # catalog happens to return first (e.g. Fireworks lists an + # image model, flux-*, ahead of its chat models). + curated = list(_PROVIDER_MODELS.get(normalized, [])) or list( + _p.fallback_models or () + ) if curated: if normalized in _LIVE_FIRST_PICKER_PROVIDERS: primary, secondary = live, curated @@ -2729,7 +2840,7 @@ def _fetch_anthropic_models( _anthropic_models_url(base_url), headers=h, ) - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: return json.loads(resp.read().decode()) try: @@ -2844,7 +2955,7 @@ def fetch_github_model_catalog( for headers in attempts: req = urllib.request.Request(COPILOT_MODELS_URL, headers=headers) try: - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) items = _payload_items(data) models: list[dict[str, Any]] = [] @@ -2961,7 +3072,7 @@ def _lmstudio_fetch_raw_models( headers = _lmstudio_request_headers(api_key) request = urllib.request.Request(server_root + "/api/v1/models", headers=headers) try: - with urllib.request.urlopen(request, timeout=timeout) as resp: + with _urlopen_model_catalog_request(request, timeout=timeout) as resp: payload = json.loads(resp.read().decode()) except urllib.error.HTTPError as exc: if exc.code in {401, 403}: @@ -3098,15 +3209,13 @@ def ensure_lmstudio_model_loaded( load_headers = dict(headers) load_headers["Content-Type"] = "application/json" try: - with urllib.request.urlopen( - urllib.request.Request( - server_root + "/api/v1/models/load", - data=body, - headers=load_headers, - method="POST", - ), - timeout=timeout, - ) as resp: + load_request = urllib.request.Request( + server_root + "/api/v1/models/load", + data=body, + headers=load_headers, + method="POST", + ) + with _urlopen_model_catalog_request(load_request, timeout=timeout) as resp: resp.read() except Exception: return None @@ -3554,6 +3663,8 @@ def probe_api_models( tried: list[str] = [] headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT} + if urllib.parse.urlparse(normalized).hostname == "generativelanguage.googleapis.com": + headers["X-Goog-Api-Client"] = f"hermes-agent/{_HERMES_VERSION}" if api_key and api_mode == "anthropic_messages": headers["x-api-key"] = api_key headers["anthropic-version"] = "2023-06-01" @@ -3573,7 +3684,7 @@ def probe_api_models( tried.append(url) req = urllib.request.Request(url, headers=headers) try: - with urllib.request.urlopen(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) return { "models": [m.get("id", "") for m in data.get("data", [])], @@ -3594,6 +3705,227 @@ def probe_api_models( } +# Legacy filter — used when an item has no surface tag (rolling out +# 2026-05). Once every model returned by the catalog endpoint carries an +# explicit surface tag (``chat``/``embed``/``image-gen``/``tts``/``stt``) +# the regex path becomes unreachable and can be removed. +_DEEPINFRA_EXCLUDE_RE = re.compile( + r"(?i)(embed|rerank|whisper|stable-diffusion|flux|sdxl|" + r"tts|bark|speech|image-gen|clip|vit-|dpt-)", +) + +# Surface tags announce *what kind of model* this is. When none of these +# are present on a catalog entry, the tags array only carries capability +# tags (``reasoning``, ``vision``, ``prompt_cache``, …) and we have to +# fall back to id-regex inference for the chat surface. +_DEEPINFRA_SURFACE_TAGS: frozenset[str] = frozenset({ + "chat", "embed", "image-gen", "tts", "stt", "video-gen", +}) + +_DEEPINFRA_DEFAULT_BASE_URL = "https://api.deepinfra.com/v1/openai" +_DEEPINFRA_MODELS_QUERY = "filter=true&sort_by=hermes" + +# Module-level cache for the full tagged catalog response, keyed by base URL. +# Each value is the parsed ``data`` list. Surface-specific filters read from +# this cache so a single network round-trip serves chat / image-gen / tts / +# stt callers across the whole process lifetime. +_deepinfra_catalog_cache: dict[str, list[dict]] = {} + +# Negative cache: monotonic timestamp of the last failed fetch, keyed by base +# URL. Without this, an unreachable catalog (offline / DNS / firewall) makes +# every surface helper (chat picker, pricing, image/video/tts/stt defaults, +# vision) re-attempt a fresh blocking fetch that eats the full timeout each +# time — several sequential stalls in one user-visible operation. A short TTL +# lets connectivity recover without a process restart. +_deepinfra_catalog_neg_cache: dict[str, float] = {} +_DEEPINFRA_CATALOG_NEG_TTL = 60.0 # seconds + + +def _deepinfra_catalog_url() -> tuple[str, str]: + """Return ``(cache_key, full_url)`` for the DeepInfra catalog endpoint.""" + base = os.getenv("DEEPINFRA_BASE_URL", "").strip() or _DEEPINFRA_DEFAULT_BASE_URL + cache_key = base.rstrip("/") + return cache_key, f"{cache_key}/models?{_DEEPINFRA_MODELS_QUERY}" + + +def _fetch_deepinfra_catalog( + *, + timeout: float = 5.0, + force_refresh: bool = False, +) -> Optional[list[dict]]: + """Fetch the raw DeepInfra catalog list with module-level caching. + + The endpoint serves chat + embed + image-gen + tts + stt models in one + response. Authentication is optional but Bearer-attached when available + so user-scoped catalogs (private fine-tunes etc.) are visible. + """ + cache_key, url = _deepinfra_catalog_url() + if not force_refresh: + if cache_key in _deepinfra_catalog_cache: + return _deepinfra_catalog_cache[cache_key] + last_fail = _deepinfra_catalog_neg_cache.get(cache_key) + if last_fail is not None and (time.monotonic() - last_fail) < _DEEPINFRA_CATALOG_NEG_TTL: + return None + + headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT} + api_key = os.getenv("DEEPINFRA_API_KEY", "").strip() + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + req = urllib.request.Request(url, headers=headers) + try: + with _urlopen_model_catalog_request(req, timeout=timeout) as resp: + payload = json.loads(resp.read().decode()) + except Exception: + _deepinfra_catalog_neg_cache[cache_key] = time.monotonic() + return None + + data = payload.get("data") + if not isinstance(data, list): + _deepinfra_catalog_neg_cache[cache_key] = time.monotonic() + return None + + _deepinfra_catalog_cache[cache_key] = data + _deepinfra_catalog_neg_cache.pop(cache_key, None) + return data + + +def _fetch_deepinfra_models_by_tag( + tag: str, + *, + timeout: float = 5.0, + force_refresh: bool = False, +) -> Optional[list[dict]]: + """Return DeepInfra models whose ``metadata.tags`` includes *tag*. + + Each returned item is ``{"id": str, "metadata": dict}`` so callers can + inspect context length, pricing, default dimensions (image-gen), + pricing units (tts ``input_characters``, stt ``input_seconds``), etc. + + For the chat surface, items without any ``tags`` field fall through + to the legacy name-regex exclusion so this keeps working while the + tag rollout (mid-2026) is still in flight. + + Returns ``None`` on network failure. + """ + data = _fetch_deepinfra_catalog(timeout=timeout, force_refresh=force_refresh) + if data is None: + return None + + matched: list[dict] = [] + for item in data: + mid = item.get("id") + if not mid: + continue + # ``metadata is None`` means DeepInfra returns a stub without + # pricing/context — typically a model that's listed but not + # served. Skip those for every surface. + raw_metadata = item.get("metadata") + if raw_metadata is None: + continue + metadata = raw_metadata if isinstance(raw_metadata, dict) else {} + raw_tags = metadata.get("tags") + tags = raw_tags if isinstance(raw_tags, list) else [] + has_surface_tag = any(t in _DEEPINFRA_SURFACE_TAGS for t in tags) + + if has_surface_tag: + if tag in tags: + matched.append({"id": mid, "metadata": metadata}) + continue + # Surface-tag rollout incomplete — fall back to id-regex inference. + # Only meaningful for the chat surface; embed/image-gen/tts/stt + # cannot be safely inferred from an id alone. + if tag == "chat" and not _DEEPINFRA_EXCLUDE_RE.search(mid): + matched.append({"id": mid, "metadata": metadata}) + + return matched + + +def _fetch_deepinfra_models( + timeout: float = 5.0, + *, + force_refresh: bool = False, +) -> Optional[list[str]]: + """Return DeepInfra chat-model ids (tag-aware, regex fallback). + + Thin wrapper over :func:`_fetch_deepinfra_models_by_tag` so historical + callers in :func:`provider_model_ids` keep their string-list contract. + Returns ``None`` on network failure, an empty list if the catalog + contains no chat-tagged ids (which would itself be surprising). + """ + items = _fetch_deepinfra_models_by_tag( + "chat", timeout=timeout, force_refresh=force_refresh + ) + if items is None: + return None + return [item["id"] for item in items] or None + + +def deepinfra_model_ids(tag: str, *, force_refresh: bool = False) -> list[str]: + """Return DeepInfra model ids carrying surface *tag* (``[]`` on failure). + + Single source of truth for the per-surface model shims (TTS/STT/vision), + replacing the copy-pasted ``import _fetch_deepinfra_models_by_tag → fetch + → [item["id"] …]`` wrapper each of them used to carry. + """ + items = _fetch_deepinfra_models_by_tag(tag, force_refresh=force_refresh) + return [item["id"] for item in items] if items else [] + + +def deepinfra_base_url(section: Optional[dict] = None) -> str: + """Resolve the DeepInfra OpenAI-compatible base URL, normalized. + + Precedence: config-section ``base_url`` → ``DEEPINFRA_BASE_URL`` env → + default. Always stripped with any trailing slash removed. Single source + of truth for the base-URL chain the TTS/STT/image/video shims each used + to re-code (with subtly divergent normalization). + """ + candidate = section.get("base_url") if isinstance(section, dict) else None + value = candidate or os.getenv("DEEPINFRA_BASE_URL") or _DEEPINFRA_DEFAULT_BASE_URL + return str(value).strip().rstrip("/") + + +def _fetch_deepinfra_pricing( + timeout: float = 5.0, + *, + force_refresh: bool = False, +) -> dict[str, dict[str, str]]: + """Return picker-shape pricing for DeepInfra chat models. + + DeepInfra publishes ``input_tokens`` / ``output_tokens`` / + ``cache_read_tokens`` in $/MTok; the picker expects per-token strings + under ``prompt`` / ``completion`` / ``input_cache_read`` (mirrors the + OpenRouter shape consumed by + :func:`format_model_pricing_table`). Cached via the catalog helper so + repeated picker renders are free. + """ + items = _fetch_deepinfra_models_by_tag( + "chat", timeout=timeout, force_refresh=force_refresh + ) + if not items: + return {} + + result: dict[str, dict[str, str]] = {} + for item in items: + metadata = item.get("metadata") or {} + pricing = metadata.get("pricing") if isinstance(metadata, dict) else None + if not isinstance(pricing, dict): + continue + entry: dict[str, str] = {} + inp = pricing.get("input_tokens") + out = pricing.get("output_tokens") + cache_read = pricing.get("cache_read_tokens") + if inp is not None: + entry["prompt"] = str(float(inp) / 1_000_000) + if out is not None: + entry["completion"] = str(float(out) / 1_000_000) + if cache_read is not None: + entry["input_cache_read"] = str(float(cache_read) / 1_000_000) + if entry: + result[item["id"]] = entry + return result + + def fetch_api_models( api_key: Optional[str], base_url: Optional[str], diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index a4c6ca9cc256..e7db628a7c1d 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -150,6 +150,12 @@ def _write_usage_file(path: Optional[str], result: dict, failure: Optional[str] "session_id": result.get("session_id"), "completed": result.get("completed"), "failed": bool(result.get("failed")) or failure is not None, + # Billing-audit field: the service tier this run REQUESTED via + # request_overrides.extra_body (e.g. OpenAI "flex"). None when + # unset. Lets batch pipelines verify the tier they think they're + # paying for actually went out on the wire (July 2026 incident: + # a config-matching bug silently dropped flex -> 2.3x billing). + "service_tier": result.get("service_tier"), } if failure is not None: report["failure"] = failure diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index ea0b8ea2ffe1..6ca393fca53c 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -172,17 +172,19 @@ VALID_HOOKS: Set[str] = { # Kwargs: event: MessageEvent, gateway: GatewayRunner, session_store. "pre_gateway_dispatch", # Approval lifecycle hooks. Fired by tools/approval.py when a dangerous - # command needs user approval -- fires BOTH for CLI-interactive prompts - # and for gateway/ACP approvals (Telegram, Discord, Slack, TUI, etc.). + # command needs an approval decision -- fires for CLI-interactive prompts, + # gateway/ACP approvals, and smart-mode auxiliary-LLM decisions. # Observers only: return values are ignored. Plugins cannot veto or # pre-answer an approval from these hooks (use pre_tool_call to block # a tool before it reaches approval). # # Kwargs for pre_approval_request: # command: str, description: str, pattern_key: str, pattern_keys: list[str], - # session_key: str, surface: "cli" | "gateway" + # session_key: str, surface: "cli" | "gateway" | "smart" # Kwargs for post_approval_response: same as above plus # choice: "once" | "session" | "always" | "deny" | "timeout" + # | "smart_approve" | "smart_deny" + # decided_by: "aux_llm" -- only on surface="smart" "pre_approval_request", "post_approval_response", # Kanban task lifecycle hooks. Fired by hermes_cli.kanban_db when a task diff --git a/hermes_cli/profile_describer.py b/hermes_cli/profile_describer.py index f80d1f5451e3..c6af27ae227f 100644 --- a/hermes_cli/profile_describer.py +++ b/hermes_cli/profile_describer.py @@ -210,23 +210,11 @@ def describe_profile( model, provider = None, None try: - from agent.auxiliary_client import ( # type: ignore - get_auxiliary_extra_body, - get_text_auxiliary_client, - ) + from agent.auxiliary_client import call_llm # type: ignore except Exception as exc: logger.debug("describe: auxiliary client import failed: %s", exc) return DescribeOutcome(canon, False, "auxiliary client unavailable") - try: - client, aux_model = get_text_auxiliary_client("profile_describer") - except Exception as exc: - logger.debug("describe: get_text_auxiliary_client failed: %s", exc) - return DescribeOutcome(canon, False, "auxiliary client unavailable") - - if client is None or not aux_model: - return DescribeOutcome(canon, False, "no auxiliary client configured") - user_msg = _USER_TEMPLATE.format( name=canon, model=(model or "(unset)"), @@ -237,8 +225,11 @@ def describe_profile( ) try: - resp = client.chat.completions.create( - model=aux_model, + # Route through call_llm so auxiliary.profile_describer.* config + # (provider/model/base_url, extra_body, reasoning_effort, retries) + # all apply — the direct-create path dropped extra_body (#35566). + resp = call_llm( + task="profile_describer", messages=[ {"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user_msg}, @@ -246,7 +237,6 @@ def describe_profile( temperature=0.3, max_tokens=400, timeout=timeout or 60, - extra_body=get_auxiliary_extra_body() or None, ) except Exception as exc: logger.info("describe: API call failed for %s (%s)", canon, exc) diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 0c2a45183151..2880b33fc4d0 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -196,6 +196,17 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { base_url_override="https://api.gmi-serving.com/v1", base_url_env_var="GMI_BASE_URL", ), + "fireworks": HermesOverlay( + transport="openai_chat", + extra_env_vars=("FIREWORKS_API_KEY",), + base_url_override="https://api.fireworks.ai/inference/v1", + ), + "upstage": HermesOverlay( + transport="openai_chat", + extra_env_vars=("UPSTAGE_API_KEY",), + base_url_override="https://api.upstage.ai/v1", + base_url_env_var="UPSTAGE_BASE_URL", + ), "ollama-cloud": HermesOverlay( transport="openai_chat", base_url_override="https://ollama.com/v1", @@ -343,6 +354,13 @@ ALIASES: Dict[str, str] = { "gmi-cloud": "gmi", "gmicloud": "gmi", + # fireworks + "fireworks-ai": "fireworks", + "fw": "fireworks", + + # upstage + "solar": "upstage", + # Local server aliases → virtual "local" concept (resolved via user config) "lmstudio": "lmstudio", "lm-studio": "lmstudio", @@ -367,6 +385,7 @@ _LABEL_OVERRIDES: Dict[str, str] = { "stepfun": "StepFun Step Plan", "xiaomi": "Xiaomi MiMo", "gmi": "GMI Cloud", + "upstage": "Upstage Solar", "tencent-tokenhub": "Tencent TokenHub", "lmstudio": "LM Studio", "local": "Local endpoint", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index ddc7ccecd507..fba92e9b350d 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -11,7 +11,13 @@ from typing import Any, Dict, Optional logger = logging.getLogger(__name__) from hermes_cli import auth as auth_mod -from agent.credential_pool import CredentialPool, PooledCredential, get_custom_provider_pool_key, load_pool +from agent.credential_pool import ( + CredentialPool, + PooledCredential, + credential_pool_matches_provider, + get_custom_provider_pool_key, + load_pool, +) from agent.secret_scope import get_secret as _get_secret from hermes_cli.auth import ( AuthError, @@ -1731,7 +1737,19 @@ def resolve_runtime_provider( if not pool_api_key or not _agent_key_is_usable(nous_state, min_ttl): logger.debug("Nous pool entry agent_key still unavailable, falling through to runtime resolution") pool_api_key = "" - if entry is not None and pool_api_key: + if ( + entry is not None + and pool_api_key + and credential_pool_matches_provider( + pool, + provider, + base_url=( + getattr(entry, "runtime_base_url", None) + or getattr(entry, "base_url", None) + or "" + ), + ) + ): return _resolve_runtime_from_pool_entry( provider=provider, entry=entry, @@ -1957,8 +1975,15 @@ def resolve_runtime_provider( # Dual-path routing: Claude models use AnthropicBedrock SDK for full # feature parity (prompt caching, thinking budgets, adaptive thinking). # Non-Claude models use the Converse API for multi-model support. + # + # Exception: Bearer Token auth (AWS_BEARER_TOKEN_BEDROCK) is NOT + # supported by the AnthropicBedrock SDK (it only does SigV4 signing — + # a bearer-only setup fails at runtime with "could not resolve + # credentials from session"). Route these users through the Converse + # API regardless of model. Ref: #28156. _current_model = str(target_model or model_cfg.get("default") or "").strip() - if is_anthropic_bedrock_model(_current_model): + _has_bearer_token = bool(os.environ.get("AWS_BEARER_TOKEN_BEDROCK", "").strip()) + if is_anthropic_bedrock_model(_current_model) and not _has_bearer_token: # Claude on Bedrock → AnthropicBedrock SDK → anthropic_messages path runtime = { "provider": "bedrock", @@ -1989,6 +2014,20 @@ def resolve_runtime_provider( pconfig = PROVIDER_REGISTRY.get(provider) if pconfig and pconfig.auth_type == "api_key": creds = resolve_api_key_provider_credentials(provider) + # An explicitly selected API-key provider is authoritative. Returning + # a runtime with an empty key defers failure until the first request and + # can make a later fallback look like a silent provider switch. Fail at + # resolution so callers surface the missing credential (or consult only + # an explicitly configured fallback chain). LM Studio's no-auth path + # supplies a non-empty placeholder in the credential resolver above. + if not has_usable_secret(creds.get("api_key")): + env_names = ", ".join(pconfig.api_key_env_vars) + hint = f" Set {env_names}." if env_names else "" + raise AuthError( + f"No usable credentials found for provider '{provider}'.{hint}", + provider=provider, + code="missing_api_key", + ) # Honour model.base_url from config.yaml when the configured provider # matches this provider — mirrors the Anthropic path above. Without # this, users who set model.base_url to e.g. api.minimaxi.com/anthropic diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index 6b3821ed03e8..24a5a2bcac0b 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -8,7 +8,9 @@ Enhanced with UI-UX-PRO-MAX design intelligence. import json import datetime +import secrets from typing import Any, Dict, List +from urllib.parse import quote # --- Icons (Lucide-style SVGs) --- ICON_USER = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' @@ -26,6 +28,7 @@ HTML_TEMPLATE = """<!DOCTYPE html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-{script_nonce}'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'"> <title>{page_title} @@ -566,13 +569,13 @@ HTML_TEMPLATE = """ - ", + "arguments": "{}", + } + } + ], + } + ] + + html = _generate_messages_html(messages) + + # Raw, executable markup must never reach the standalone artifact. + assert "" not in html + # The escaped form must be present instead. + assert "<script>alert(1)</script>" in html + + +def test_role_is_escaped_in_html_export(): + messages = [ + { + "role": "", + "content": "hello", + "timestamp": 1700000000, + } + ] + + html = _generate_messages_html(messages) + + assert "" not in html + assert "<img src=x onerror=alert(document.domain)>" in html + # The class attribute must remain a single, well-formed token: a crafted + # role must not break out of it nor split into several unintended classes. + class_value = re.search(r'class="(message message-[^"]*active)"', html) + assert class_value is not None + assert " message-" in class_value.group(1) # exactly one message- class + assert class_value.group(1).count("message-") == 1 + + +def test_known_role_keeps_its_css_class(): + html = _generate_messages_html( + [{"role": "assistant", "content": "hi", "timestamp": 1700000000}] + ) + assert 'class="message message-assistant active"' in html diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index 2405b84a3814..6d31d558f157 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -249,6 +249,46 @@ class TestListNavigation: assert allowlist[1] == {"name": "bob", "role": "admin"} +# --------------------------------------------------------------------------- +# String-typed config values — regression tests for #47515 +# --------------------------------------------------------------------------- + +class TestStringTypedConfigValues: + @pytest.mark.parametrize("value", ["off", "on", "yes", "no", "true", "false", "01"]) + def test_string_typed_values_are_not_coerced(self, _isolated_hermes_home, value): + """Values stay strings when DEFAULT_CONFIG declares the leaf as a string.""" + set_config_value("approvals.mode", value) + + import yaml + saved = yaml.safe_load(_read_config(_isolated_hermes_home)) + assert saved["approvals"]["mode"] == value + assert isinstance(saved["approvals"]["mode"], str) + + @pytest.mark.parametrize("key, value, expected", [ + ("terminal.persistent_shell", "off", False), + ("approvals.timeout", "30", 30), + ]) + def test_non_string_defaults_keep_existing_coercion( + self, _isolated_hermes_home, key, value, expected + ): + set_config_value(key, value) + + import yaml + saved = yaml.safe_load(_read_config(_isolated_hermes_home)) + node = saved + for part in key.split("."): + node = node[part] + assert node == expected + assert type(node) is type(expected) + + def test_unknown_keys_keep_existing_coercion(self, _isolated_hermes_home): + set_config_value("custom.enabled", "off") + + import yaml + saved = yaml.safe_load(_read_config(_isolated_hermes_home)) + assert saved["custom"]["enabled"] is False + + # --------------------------------------------------------------------------- # Secret redaction in display output (issue #50245) # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_setup_irc.py b/tests/hermes_cli/test_setup_irc.py index 31b263fec353..e74185076554 100644 --- a/tests/hermes_cli/test_setup_irc.py +++ b/tests/hermes_cli/test_setup_irc.py @@ -231,6 +231,18 @@ class TestIRCGatewaySetupFreshInstall: monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *a, **kw: False) monkeypatch.setattr(setup_mod, "prompt_choice", lambda *a, **kw: 0) + # Select ONLY the IRC row. Without this, the non-TTY checklist + # falls back to its cancel value (the pre-selected "configured" + # platforms) — on a dev machine with real platforms configured + # that runs their interactive setup_fn, which calls input() and + # dies under captured stdin. IRC's setup_fn is a no-op lambda. + monkeypatch.setattr( + setup_mod, + "prompt_checklist", + lambda title, items, pre=None: [ + i for i, item in enumerate(items) if "IRC" in item + ], + ) monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False) monkeypatch.setattr(gateway_mod, "is_macos", lambda: False) monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False) diff --git a/tests/hermes_cli/test_skills_config.py b/tests/hermes_cli/test_skills_config.py index 8fbb063f620e..31365fcf2275 100644 --- a/tests/hermes_cli/test_skills_config.py +++ b/tests/hermes_cli/test_skills_config.py @@ -46,6 +46,34 @@ class TestGetDisabledSkills: from hermes_cli.skills_config import get_disabled_skills assert get_disabled_skills({"other": "value"}) == set() + def test_null_skills_section(self): + """``skills:`` with no value (YAML null) must not crash (#13026).""" + from hermes_cli.skills_config import get_disabled_skills + assert get_disabled_skills({"skills": None}) == set() + assert get_disabled_skills({"skills": None}, platform="telegram") == set() + + def test_null_disabled_key(self): + from hermes_cli.skills_config import get_disabled_skills + assert get_disabled_skills({"skills": {"disabled": None}}) == set() + + def test_scalar_disabled_is_single_skill_not_characters(self): + """``disabled: my-skill`` (bare scalar) is one skill name, not a + set of its characters (#13026).""" + from hermes_cli.skills_config import get_disabled_skills + assert get_disabled_skills({"skills": {"disabled": "my-skill"}}) == {"my-skill"} + + def test_scalar_platform_disabled(self): + from hermes_cli.skills_config import get_disabled_skills + config = {"skills": { + "disabled": ["global-skill"], + "platform_disabled": {"telegram": "tg-skill"}, + }} + assert get_disabled_skills(config, platform="telegram") == {"global-skill", "tg-skill"} + + def test_non_dict_skills_section(self): + from hermes_cli.skills_config import get_disabled_skills + assert get_disabled_skills({"skills": "oops"}) == set() + def test_empty_disabled_list(self): from hermes_cli.skills_config import get_disabled_skills assert get_disabled_skills({"skills": {"disabled": []}}) == set() diff --git a/tests/hermes_cli/test_slack_cli.py b/tests/hermes_cli/test_slack_cli.py index bdc937a59c8d..6de85a3007c0 100644 --- a/tests/hermes_cli/test_slack_cli.py +++ b/tests/hermes_cli/test_slack_cli.py @@ -15,7 +15,7 @@ def _parse_slack_args(argv): class TestSlackManifestArgparse: - """The `--no-assistant` flag wires through argparse to `no_assistant`.""" + """Slack manifest messaging-experience flags wire through argparse.""" def test_no_assistant_flag_defaults_false(self): args = _parse_slack_args(["slack", "manifest"]) @@ -25,6 +25,13 @@ class TestSlackManifestArgparse: args = _parse_slack_args(["slack", "manifest", "--no-assistant"]) assert args.no_assistant is True + def test_agent_view_flag_defaults_false(self): + args = _parse_slack_args(["slack", "manifest"]) + assert getattr(args, "agent_view", False) is False + + def test_agent_view_flag_sets_true(self): + args = _parse_slack_args(["slack", "manifest", "--agent-view"]) + assert args.agent_view is True class TestSlackFullManifest: @@ -77,6 +84,7 @@ class TestSlackFullManifest: manifest = _build_full_manifest("Hermes", "Your Hermes agent on Slack") assert "assistant_view" in manifest["features"] + assert "agent_view" not in manifest["features"] assert "assistant:write" in manifest["oauth_config"]["scopes"]["bot"] bot_events = manifest["settings"]["event_subscriptions"]["bot_events"] assert "assistant_thread_started" in bot_events @@ -89,11 +97,39 @@ class TestSlackFullManifest: # assistant_view feature is gone -> Slack renders a flat DM, not the # Assistant thread pane (where bare slash commands don't dispatch). assert "assistant_view" not in manifest["features"] + assert "agent_view" not in manifest["features"] assert "assistant:write" not in manifest["oauth_config"]["scopes"]["bot"] bot_events = manifest["settings"]["event_subscriptions"]["bot_events"] assert "assistant_thread_started" not in bot_events assert "assistant_thread_context_changed" not in bot_events + def test_agent_view_uses_agent_manifest_surface(self): + manifest = _build_full_manifest( + "Hermes", + "Your Hermes agent on Slack", + messaging_experience="agent", + ) + + assert manifest["features"]["agent_view"] == { + "agent_description": "Chat with Hermes in Slack Messages.", + } + assert "assistant_view" not in manifest["features"] + assert "assistant:write" in manifest["oauth_config"]["scopes"]["bot"] + + def test_agent_view_uses_agent_event_subscriptions(self): + manifest = _build_full_manifest( + "Hermes", + "Your Hermes agent on Slack", + messaging_experience="agent", + ) + + bot_events = manifest["settings"]["event_subscriptions"]["bot_events"] + assert "app_home_opened" in bot_events + assert "app_context_changed" in bot_events + assert "message.im" in bot_events + assert "assistant_thread_started" not in bot_events + assert "assistant_thread_context_changed" not in bot_events + def test_no_assistant_preserves_core_surface(self): """Dropping assistant mode must NOT strip the regular messaging surface.""" manifest = _build_full_manifest( diff --git a/tests/hermes_cli/test_upstage_provider.py b/tests/hermes_cli/test_upstage_provider.py new file mode 100644 index 000000000000..6f7da10761f7 --- /dev/null +++ b/tests/hermes_cli/test_upstage_provider.py @@ -0,0 +1,114 @@ +"""Focused tests for Upstage Solar first-class provider wiring. + +Regression guard for the bug where `hermes model` saved `provider: upstage` +correctly but, on re-entry, showed a different provider as active. Root cause: +`hermes_cli/providers.py` (the resolver behind `resolve_provider_full`) had no +`upstage` overlay, so `resolve_provider_full("upstage")` returned None, the +config provider was discarded, and resolution fell through to env auto-detect. +""" + +from __future__ import annotations + +import sys +import types + + +if "dotenv" not in sys.modules: + fake_dotenv = types.ModuleType("dotenv") + fake_dotenv.load_dotenv = lambda *args, **kwargs: None + sys.modules["dotenv"] = fake_dotenv + + +class TestUpstageResolver: + """The providers.py resolver must recognise upstage (the actual bug).""" + + def test_resolve_provider_full_recognizes_upstage(self): + from hermes_cli.providers import resolve_provider_full + + pdef = resolve_provider_full("upstage", {}, []) + assert pdef is not None, ( + "resolve_provider_full('upstage') returned None — config " + "`provider: upstage` would be discarded and auto-detect would win" + ) + assert pdef.id == "upstage" + assert pdef.base_url == "https://api.upstage.ai/v1" + assert "UPSTAGE_API_KEY" in pdef.api_key_env_vars + + def test_get_provider_returns_upstage_def(self): + from hermes_cli.providers import get_provider + + pdef = get_provider("upstage") + assert pdef is not None and pdef.id == "upstage" + assert pdef.transport == "openai_chat" + + def test_solar_alias_normalizes_to_upstage(self): + from hermes_cli.providers import normalize_provider, resolve_provider_full + + assert normalize_provider("solar") == "upstage" + pdef = resolve_provider_full("solar", {}, []) + assert pdef is not None and pdef.id == "upstage" + + +class TestUpstageOverlay: + def test_overlay_exists(self): + from hermes_cli.providers import HERMES_OVERLAYS + + assert "upstage" in HERMES_OVERLAYS + overlay = HERMES_OVERLAYS["upstage"] + assert overlay.transport == "openai_chat" + assert overlay.extra_env_vars == ("UPSTAGE_API_KEY",) + assert overlay.base_url_override == "https://api.upstage.ai/v1" + assert overlay.base_url_env_var == "UPSTAGE_BASE_URL" + assert not overlay.is_aggregator + + def test_provider_label(self): + from hermes_cli.providers import get_label + + assert get_label("upstage") == "Upstage Solar" + + +class TestUpstageEnvCatalog: + """The dashboard/desktop Providers page lists only OPTIONAL_ENV_VARS keys + whose category is "provider". Without these entries UPSTAGE_API_KEY / + UPSTAGE_BASE_URL never reach the frontend and Upstage stays invisible even + though EnvPage.tsx has a matching PROVIDER_GROUPS prefix. + """ + + def test_optional_env_vars_include_upstage(self): + from hermes_cli.config import OPTIONAL_ENV_VARS + + assert "UPSTAGE_API_KEY" in OPTIONAL_ENV_VARS + assert OPTIONAL_ENV_VARS["UPSTAGE_API_KEY"]["category"] == "provider" + assert OPTIONAL_ENV_VARS["UPSTAGE_API_KEY"]["password"] is True + assert OPTIONAL_ENV_VARS["UPSTAGE_API_KEY"]["url"] + + assert "UPSTAGE_BASE_URL" in OPTIONAL_ENV_VARS + assert OPTIONAL_ENV_VARS["UPSTAGE_BASE_URL"]["category"] == "provider" + assert OPTIONAL_ENV_VARS["UPSTAGE_BASE_URL"]["password"] is False + + +class TestUpstageConfigProviderWins: + """End-to-end: an explicit config provider must beat env auto-detect. + + Mirrors the display logic in `hermes_cli/main.py` (cmd_model): read + `model.provider`, resolve it, and only fall back to auto-detect when that + resolution fails. With a stray DEEPSEEK_API_KEY present (the user's case), + upstage must still win because it is configured explicitly. + """ + + def test_explicit_upstage_beats_stray_deepseek_key(self, monkeypatch): + from hermes_cli.providers import resolve_provider_full + + monkeypatch.setenv("DEEPSEEK_API_KEY", "junk") + monkeypatch.setenv("UPSTAGE_API_KEY", "up-test-key") + + config_provider = "upstage" # from config model.provider + active = "" + if config_provider and config_provider != "auto": + adef = resolve_provider_full(config_provider, {}, []) + active = adef.id if adef is not None else "" + + assert active == "upstage", ( + "explicit config provider should resolve to upstage, not fall " + "through to deepseek auto-detect" + ) diff --git a/tests/hermes_cli/test_urllib_security.py b/tests/hermes_cli/test_urllib_security.py new file mode 100644 index 000000000000..d21ac3bb030e --- /dev/null +++ b/tests/hermes_cli/test_urllib_security.py @@ -0,0 +1,561 @@ +"""Wire-level tests for credential-safe stdlib urllib redirects.""" + +from __future__ import annotations + +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread +import urllib.error +import urllib.request + +import pytest + +from hermes_cli.urllib_security import ( + SafeCredentialRedirectHandler, + open_credentialed_url, + url_origin, +) + + +class _Response: + def __init__(self, payload: bytes = b"{}") -> None: + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self) -> bytes: + return self._payload + + +class _RecordingHandler(BaseHTTPRequestHandler): + redirect_to = "" + redirect_status = 302 + requests: list[tuple[str, dict[str, str]]] = [] + + def _record(self) -> None: + type(self).requests.append( + (self.command, {name.lower(): value for name, value in self.headers.items()}) + ) + + def do_GET(self): + if self.path.startswith("/redirect"): + self.send_response(type(self).redirect_status) + self.send_header("Location", type(self).redirect_to) + self.end_headers() + return + self._record() + body = json.dumps({"data": []}).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if self.path == "/redirect": + self.send_response(type(self).redirect_status) + self.send_header("Location", type(self).redirect_to) + self.end_headers() + return + self._record() + self.send_response(200) + self.end_headers() + + def log_message(self, _format, *_args): + pass + + +def _server(): + server = ThreadingHTTPServer(("127.0.0.1", 0), _RecordingHandler) + Thread(target=server.serve_forever, daemon=True).start() + return server + + +def _credential_headers() -> dict[str, str]: + return { + "Authorization": "Bearer secret", + "Cookie": "session=secret", + "CF-Access-Client-Secret": "cloudflare-secret", + "X-Custom-Auth": "tenant-secret", + "Accept": "application/json", + "User-Agent": "hermes-test", + } + + +def test_url_origin_normalizes_default_ports_and_trailing_dot(): + assert url_origin("https://EXAMPLE.test./models") == ( + "https", + "example.test", + 443, + ) + assert url_origin("https://example.test:443/other") == ( + "https", + "example.test", + 443, + ) + assert url_origin("http://example.test") != url_origin("https://example.test") + assert url_origin("https://example.test:0") == ( + "https", + "example.test", + 0, + ) + with pytest.raises(ValueError): + url_origin("https://example.test:not-a-port") + + +def test_cross_host_redirect_drops_arbitrary_credentials_on_wire(): + source = _server() + sink = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + try: + request = urllib.request.Request( + f"http://127.0.0.1:{source.server_port}/redirect", + headers=_credential_headers(), + ) + with open_credentialed_url(request, timeout=3) as response: + response.read() + finally: + source.shutdown() + sink.shutdown() + + method, headers = _RecordingHandler.requests[-1] + assert method == "GET" + assert headers["accept"] == "application/json" + assert headers["user-agent"] == "hermes-test" + for name in ( + "authorization", + "cookie", + "cf-access-client-secret", + "x-custom-auth", + ): + assert name not in headers + + +def test_same_host_different_port_drops_credentials_on_wire(): + source = _server() + sink = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://127.0.0.1:{sink.server_port}/sink" + try: + request = urllib.request.Request( + f"http://127.0.0.1:{source.server_port}/redirect", + headers=_credential_headers(), + ) + with open_credentialed_url(request, timeout=3) as response: + response.read() + finally: + source.shutdown() + sink.shutdown() + + _, headers = _RecordingHandler.requests[-1] + assert "authorization" not in headers + assert "cf-access-client-secret" not in headers + + +def test_same_origin_redirect_preserves_headers_on_wire(): + server = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://127.0.0.1:{server.server_port}/sink" + try: + request = urllib.request.Request( + f"http://127.0.0.1:{server.server_port}/redirect", + headers=_credential_headers(), + ) + with open_credentialed_url(request, timeout=3) as response: + response.read() + finally: + server.shutdown() + + _, headers = _RecordingHandler.requests[-1] + assert headers["authorization"] == "Bearer secret" + assert headers["cf-access-client-secret"] == "cloudflare-secret" + + +def test_scheme_downgrade_is_cross_origin(): + request = urllib.request.Request( + "https://models.example.test/models", headers=_credential_headers() + ) + handler = SafeCredentialRedirectHandler(request.full_url) + redirected = handler.redirect_request( + request, + None, + 302, + "Found", + {}, + "http://models.example.test/models", + ) + assert redirected is not None + headers = {name.lower(): value for name, value in redirected.header_items()} + assert "authorization" not in headers + assert "cf-access-client-secret" not in headers + + +def test_post_302_uses_urllib_semantics_and_drops_credentials(): + request = urllib.request.Request( + "https://models.example.test/load", + data=b"{}", + headers={**_credential_headers(), "Content-Type": "application/json"}, + method="POST", + ) + handler = SafeCredentialRedirectHandler(request.full_url) + redirected = handler.redirect_request( + request, + None, + 302, + "Found", + {}, + "https://other.example.test/load", + ) + assert redirected is not None + assert redirected.get_method() == "GET" + assert redirected.data is None + headers = {name.lower(): value for name, value in redirected.header_items()} + assert "authorization" not in headers + assert "content-type" not in headers + + +def test_post_307_remains_rejected_by_urllib(): + request = urllib.request.Request( + "https://models.example.test/load", + data=b"{}", + headers=_credential_headers(), + method="POST", + ) + handler = SafeCredentialRedirectHandler(request.full_url) + with pytest.raises(urllib.error.HTTPError): + handler.redirect_request( + request, + None, + 307, + "Temporary Redirect", + {}, + "https://other.example.test/load", + ) + + +def test_explicit_opener_factory_is_instrumentable_without_security_bypass(): + calls = [] + + class _Opener: + def open(self, request, *, timeout): + calls.append((request.full_url, timeout)) + return _Response() + + def factory(*handlers): + assert any(isinstance(h, SafeCredentialRedirectHandler) for h in handlers) + return _Opener() + + request = urllib.request.Request( + "https://models.example.test/models", headers={"Authorization": "secret"} + ) + with open_credentialed_url(request, timeout=7, opener_factory=factory): + pass + assert calls == [("https://models.example.test/models", 7)] + + +def test_installed_custom_opener_policy_is_preserved(monkeypatch): + opened = [] + + class FooHandler(urllib.request.BaseHandler): + def foo_open(self, request): + opened.append(request.full_url) + return _Response(b"custom") + + installed = urllib.request.build_opener(FooHandler()) + installed.addheaders = [ + ("X-Trace-Policy", "installed"), + ("User-agent", "enterprise-client"), + ] + monkeypatch.setattr(urllib.request, "_opener", installed) + + from hermes_cli.urllib_security import _secure_opener_from_installed_policy + + secured = _secure_opener_from_installed_policy( + "foo://models.example.test/catalog" + ) + assert secured.addheaders == [] + assert getattr(secured, "_hermes_initial_addheaders") == installed.addheaders + + request = urllib.request.Request( + "foo://models.example.test/catalog", headers={"Authorization": "secret"} + ) + with open_credentialed_url(request, timeout=3) as response: + assert response.read() == b"custom" + request_headers = { + name.lower(): value for name, value in request.header_items() + } + assert request_headers["x-trace-policy"] == "installed" + assert request_headers["user-agent"] == "enterprise-client" + assert opened == ["foo://models.example.test/catalog"] + + +def test_installed_proxy_handler_is_preserved(monkeypatch): + installed = urllib.request.build_opener( + urllib.request.ProxyHandler({"https": "http://proxy.example.test:8443"}) + ) + monkeypatch.setattr(urllib.request, "_opener", installed) + + from hermes_cli.urllib_security import _secure_opener_from_installed_policy + + secured = _secure_opener_from_installed_policy( + "https://models.example.test/catalog" + ) + proxy_handlers = [ + handler + for handler in getattr(secured, "handlers", ()) + if isinstance(handler, urllib.request.ProxyHandler) + ] + assert proxy_handlers + assert getattr(proxy_handlers[0], "proxies", {}) == { + "https": "http://proxy.example.test:8443" + } + + +def test_installed_request_processor_cannot_resurrect_cross_origin_secret( + monkeypatch, +): + source = _server() + sink = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + + class SecretProcessor(urllib.request.BaseHandler): + handler_order = float("inf") # type: ignore[assignment] + + def http_request(self, request): + request.add_header("X-Installed-Secret", "must-not-cross") + return request + + installed = urllib.request.build_opener(SecretProcessor()) + installed.addheaders = [("X-Opener-Secret", "also-must-not-cross")] + monkeypatch.setattr(urllib.request, "_opener", installed) + try: + request = urllib.request.Request( + f"http://127.0.0.1:{source.server_port}/redirect", + headers={"Authorization": "Bearer secret"}, + ) + with open_credentialed_url(request, timeout=3) as response: + response.read() + finally: + source.shutdown() + sink.shutdown() + + _, headers = _RecordingHandler.requests[-1] + assert "authorization" not in headers + assert "x-installed-secret" not in headers + assert "x-opener-secret" not in headers + + +def test_multihop_redirects_never_resurrect_credentials(): + request = urllib.request.Request( + "https://a.example.test/models", headers=_credential_headers() + ) + handler = SafeCredentialRedirectHandler(request.full_url) + + same_origin = handler.redirect_request( + request, + None, + 302, + "Found", + {}, + "https://a.example.test/step-two", + ) + assert same_origin is not None + same_headers = { + name.lower(): value for name, value in same_origin.header_items() + } + assert "authorization" in same_headers + + cross_origin = handler.redirect_request( + same_origin, + None, + 302, + "Found", + {}, + "https://b.example.test/step-three", + ) + assert cross_origin is not None + cross_headers = { + name.lower(): value for name, value in cross_origin.header_items() + } + assert "authorization" not in cross_headers + assert "cf-access-client-secret" not in cross_headers + + returned = handler.redirect_request( + cross_origin, + None, + 302, + "Found", + {}, + "https://a.example.test/final", + ) + assert returned is not None + returned_headers = { + name.lower(): value for name, value in returned.header_items() + } + assert "authorization" not in returned_headers + assert "cf-access-client-secret" not in returned_headers + + +def test_probe_api_models_drops_custom_credentials_on_wire(): + from hermes_cli.models import probe_api_models + + source = _server() + sink = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + try: + result = probe_api_models( + "provider-key", + f"http://127.0.0.1:{source.server_port}/redirect/..", + timeout=3, + request_headers={ + "CF-Access-Client-Secret": "cloudflare-secret", + "X-Custom-Auth": "tenant-secret", + }, + ) + finally: + source.shutdown() + sink.shutdown() + + assert result["models"] == [] + _, headers = _RecordingHandler.requests[-1] + assert "authorization" not in headers + assert "cf-access-client-secret" not in headers + assert "x-custom-auth" not in headers + + +class _LmStudioSourceHandler(BaseHTTPRequestHandler): + redirect_to = "" + + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + self.send_response(302) + self.send_header("Location", type(self).redirect_to) + self.end_headers() + + def log_message(self, format, *_args): + pass + + +def test_anthropic_profile_drops_x_api_key_on_redirect(monkeypatch): + import importlib + + AnthropicProfile = importlib.import_module( + "plugins.model-providers.anthropic" + ).AnthropicProfile + + source = _server() + sink = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + + original_request = urllib.request.Request + + def local_anthropic_request(url, *args, **kwargs): + if url == "https://api.anthropic.com/v1/models": + url = f"http://127.0.0.1:{source.server_port}/redirect" + return original_request(url, *args, **kwargs) + + monkeypatch.setattr(urllib.request, "Request", local_anthropic_request) + try: + result = AnthropicProfile(name="anthropic").fetch_models( + api_key="anthropic-secret", timeout=3 + ) + finally: + source.shutdown() + sink.shutdown() + + assert result == [] + _, headers = _RecordingHandler.requests[-1] + assert "x-api-key" not in headers + assert headers["accept"] == "application/json" + + +def test_azure_catalog_probe_drops_api_key_and_bearer_on_redirect(): + from hermes_cli import azure_detect + + source = _server() + sink = _server() + _RecordingHandler.requests = [] + _RecordingHandler.redirect_status = 302 + _RecordingHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + try: + status, body = azure_detect._http_get_json( + f"http://127.0.0.1:{source.server_port}/redirect", "azure-secret", timeout=3 + ) + finally: + source.shutdown() + sink.shutdown() + + assert status == 200 + assert body == {"data": []} + _, headers = _RecordingHandler.requests[-1] + assert "authorization" not in headers + assert "api-key" not in headers + + +def test_azure_anthropic_probe_drops_api_key_and_bearer_on_redirect(): + from hermes_cli import azure_detect + + sink = _server() + source = ThreadingHTTPServer(("127.0.0.1", 0), _LmStudioSourceHandler) + Thread(target=source.serve_forever, daemon=True).start() + _RecordingHandler.requests = [] + _LmStudioSourceHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + try: + azure_detect._probe_anthropic_messages( + f"http://127.0.0.1:{source.server_port}", "azure-secret" + ) + finally: + source.shutdown() + sink.shutdown() + + _, headers = _RecordingHandler.requests[-1] + assert "authorization" not in headers + assert "api-key" not in headers + + +def test_lmstudio_load_post_drops_bearer_on_redirect(monkeypatch): + from hermes_cli import models + + sink = _server() + source = ThreadingHTTPServer(("127.0.0.1", 0), _LmStudioSourceHandler) + Thread(target=source.serve_forever, daemon=True).start() + _RecordingHandler.requests = [] + _LmStudioSourceHandler.redirect_to = f"http://localhost:{sink.server_port}/sink" + monkeypatch.setattr( + models, + "_lmstudio_fetch_raw_models", + lambda **_kwargs: [ + {"id": "model", "max_context_length": 8192, "loaded_instances": []} + ], + ) + try: + loaded = models.ensure_lmstudio_model_loaded( + "model", + f"http://127.0.0.1:{source.server_port}", + api_key="lm-secret", + target_context_length=4096, + timeout=3, + ) + finally: + source.shutdown() + sink.shutdown() + + assert loaded == 4096 + method, headers = _RecordingHandler.requests[-1] + assert method == "GET" + assert "authorization" not in headers + assert "content-type" not in headers diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index f8ee073b138c..46d770410fd9 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -340,6 +340,56 @@ def test_codex_dashboard_worker_persists_inside_session_profile(tmp_path, monkey ws._oauth_sessions.pop(sid, None) +def test_codex_dashboard_start_rewords_device_authorization_error(monkeypatch): + from hermes_cli import web_server as ws + + before_sessions = set(ws._oauth_sessions) + + class _Resp: + status_code = 400 + text = "Enable device code authorization" + + def json(self): + return { + "error": { + "message": "Enable device code authorization", + "code": "device_authorization_not_enabled", + } + } + + class _Client: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def post(self, url, **kwargs): + assert url.endswith("/deviceauth/usercode") + return _Resp() + + monkeypatch.setattr(httpx, "Client", _Client) + + try: + resp = client.post( + "/api/providers/oauth/openai-codex/start", + headers=HEADERS, + ) + + assert resp.status_code == 500 + detail = resp.json()["detail"] + assert "OpenAI rejected the device-code login request" in detail + assert "Enable device-code authorization in OpenAI" in detail + assert "click Login again" in detail + assert "hermes auth" not in detail + finally: + for sid in set(ws._oauth_sessions) - before_sessions: + ws._oauth_sessions.pop(sid, None) + + def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch): from hermes_cli import auth as auth_mod from hermes_cli import web_server as ws diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 8f7282f1b0e6..219b7f4bba25 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -267,6 +267,84 @@ class TestWebServerEndpoints: assert "active_sessions" in data assert data["can_update_hermes"] is True + def test_status_active_session_count_uses_read_only_db(self, monkeypatch, tmp_path): + import hermes_cli.web_server as web_server + import hermes_state + + # Satisfy the fresh-install guard: read_only opens require the DB + # file to already exist. + fake_db_path = tmp_path / "state.db" + fake_db_path.touch() + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", fake_db_path) + + captured = {} + + class _FakeDB: + def __init__(self, *args, **kwargs): + captured["read_only"] = kwargs.get("read_only") + + def list_sessions_rich(self, limit, compact_rows=False): + captured["limit"] = limit + captured["compact_rows"] = compact_rows + return [ + {"ended_at": None, "last_active": 95}, + {"ended_at": 99, "last_active": 99}, + {"ended_at": None, "last_active": -300}, + ] + + def close(self): + captured["closed"] = True + + monkeypatch.setattr("hermes_state.SessionDB", _FakeDB) + monkeypatch.setattr(web_server.time, "time", lambda: 100) + + assert web_server._count_status_active_sessions() == 1 + assert captured == { + "read_only": True, "limit": 50, "compact_rows": True, "closed": True + } + + def test_status_active_session_count_fresh_install_returns_zero(self, monkeypatch, tmp_path): + """No state.db yet (fresh install): return 0 without attempting a + read-only open, which would raise OperationalError on every poll.""" + import hermes_cli.web_server as web_server + import hermes_state + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "absent.db") + + def _boom(*a, **k): + raise AssertionError("SessionDB must not be constructed when db file is absent") + + monkeypatch.setattr("hermes_state.SessionDB", _boom) + assert web_server._count_status_active_sessions() == 0 + + def test_get_status_degrades_when_active_session_count_fails(self, monkeypatch): + import hermes_cli.web_server as web_server + + def _locked_count(): + raise TimeoutError("database is locked") + + monkeypatch.setattr(web_server, "_count_status_active_sessions", _locked_count) + + resp = self.client.get("/api/status") + assert resp.status_code == 200 + assert resp.json()["active_sessions"] == 0 + + def test_get_status_uses_cached_gateway_pid_probe(self, monkeypatch): + import hermes_cli.web_server as web_server + + calls = {"get_running_pid_cached": 0} + + def _cached_pid(): + calls["get_running_pid_cached"] += 1 + return None + + monkeypatch.setattr(web_server, "get_running_pid_cached", _cached_pid) + + resp = self.client.get("/api/status") + + assert resp.status_code == 200 + assert calls["get_running_pid_cached"] == 1 + def test_gateway_drain_begin_writes_marker(self): from gateway import drain_control @@ -423,6 +501,55 @@ class TestWebServerEndpoints: assert data["actions"] == [] assert data["docs_url"] == "" + def test_declared_surface_serves_curated_hindsight_schema(self): + resp = self.client.get("/api/memory/providers/hindsight/config?surface=declared") + + assert resp.status_code == 200 + data = resp.json() + fields = self._provider_field_map(data) + assert set(fields) == {"mode", "api_key", "api_url", "bank_id", "recall_budget"} + assert fields["mode"]["kind"] == "select" + assert fields["api_key"]["kind"] == "secret" + + def test_declared_surface_hides_undeclared_providers(self): + resp = self.client.get("/api/memory/providers/honcho/config?surface=declared") + + assert resp.status_code == 200 + assert resp.json()["fields"] == [] + + def test_declared_surface_put_writes_config_and_secret(self): + from hermes_constants import get_hermes_home + from hermes_cli.config import load_env + + resp = self.client.put( + "/api/memory/providers/hindsight/config?surface=declared", + json={ + "values": { + "mode": "local_external", + "api_url": "http://localhost:8888", + "api_key": "hs-declared-key", + } + }, + ) + + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + assert load_env()["HINDSIGHT_API_KEY"] == "hs-declared-key" + + config_path = get_hermes_home() / "hindsight" / "config.json" + provider_config = json.loads(config_path.read_text(encoding="utf-8")) + assert provider_config["mode"] == "local_external" + assert provider_config["api_url"] == "http://localhost:8888" + assert "api_key" not in provider_config + + def test_declared_surface_put_rejects_undeclared_provider(self): + resp = self.client.put( + "/api/memory/providers/honcho/config?surface=declared", + json={"values": {"api_key": "x"}}, + ) + + assert resp.status_code == 404 + def test_all_listed_memory_provider_configs_fetch(self): resp = self.client.get("/api/memory") @@ -746,6 +873,71 @@ class TestWebServerEndpoints: assert cfg["moa"]["reference_models"] == payload["reference_models"] assert cfg["moa"]["aggregator"] == payload["aggregator"] + def test_put_moa_models_rejects_half_filled_slot_with_422(self): + """#64156: a mid-edit autosave (provider picked, model empty) used to be + silently normalized into the hardcoded default preset — the user's + config was replaced without any error. The write path must reject it.""" + from hermes_cli.config import load_config + + original = load_config().get("moa") + + payload = { + "presets": { + "default": { + "reference_models": [{"provider": "kilo", "model": ""}], + "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + } + } + } + + resp = self.client.put("/api/model/moa", json=payload) + assert resp.status_code == 422 + assert "model is required" in resp.json()["detail"] + # Config untouched — not swapped for defaults. + assert load_config().get("moa") == original + + def test_put_moa_models_rejects_half_filled_aggregator_with_422(self): + payload = { + "presets": { + "default": { + "reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}], + "aggregator": {"provider": "openrouter", "model": ""}, + } + } + } + + resp = self.client.put("/api/model/moa", json=payload) + assert resp.status_code == 422 + assert "aggregator" in resp.json()["detail"] + + def test_put_moa_models_round_trips_fanout_and_reference_max_tokens(self): + """GET → PUT round-trip must not erase newer per-preset knobs. The old + Pydantic payload didn't declare fanout / reference_max_tokens, so any + client save silently wiped hand-set values back to defaults.""" + from hermes_cli.config import load_config + + payload = { + "presets": { + "default": { + "reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}], + "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + "fanout": "user_turn", + "reference_max_tokens": 600, + } + } + } + + resp = self.client.put("/api/model/moa", json=payload) + assert resp.status_code == 200 + + saved = load_config()["moa"]["presets"]["default"] + assert saved["fanout"] == "user_turn" + assert saved["reference_max_tokens"] == 600 + + # And the GET view carries them back to the client. + fetched = self.client.get("/api/model/moa").json() + assert fetched["presets"]["default"]["fanout"] == "user_turn" + assert fetched["presets"]["default"]["reference_max_tokens"] == 600 # ── Memory provider config (Honcho host-block backend) ────────────── @pytest.fixture(autouse=True) @@ -1122,6 +1314,107 @@ class TestWebServerEndpoints: ) assert resp.status_code == 401 + # ── POST /api/chat/image-upload (browser clipboard/drop images) ───── + + def test_chat_image_upload_writes_to_default_profile_images(self): + from hermes_constants import get_hermes_home + + data_url = ( + "data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk" + "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + ) + + resp = self.client.post( + "/api/chat/image-upload", + json={"data_url": data_url, "filename": "../../clip.png"}, + ) + + assert resp.status_code == 200 + data = resp.json() + target = Path(data["path"]) + assert data["ok"] is True + assert data["mime_type"] == "image/png" + assert target.parent == get_hermes_home() / "images" + assert target.name.startswith("dashboard_") + assert target.name.endswith("_clip.png") + assert target.is_file() + assert target.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + + def test_chat_image_upload_writes_to_requested_profile_images(self): + from hermes_cli import profiles as profiles_mod + + worker_home = profiles_mod.get_profile_dir("worker") + worker_home.mkdir(parents=True) + + resp = self.client.post( + "/api/chat/image-upload?profile=worker", + json={ + "data_url": "data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAA=", + "filename": "drop.gif", + }, + ) + + assert resp.status_code == 200 + target = Path(resp.json()["path"]) + assert target.parent == worker_home / "images" + assert target.is_file() + assert target.read_bytes().startswith(b"GIF89a") + + def test_chat_image_upload_rejects_non_image_payload(self): + resp = self.client.post( + "/api/chat/image-upload", + json={"data_url": "data:text/plain;base64,aGVsbG8="}, + ) + + assert resp.status_code == 400 + assert "image" in resp.json()["detail"].lower() + + def test_chat_image_upload_rejects_spoofed_image_payload(self): + resp = self.client.post( + "/api/chat/image-upload", + json={"data_url": "data:image/png;base64,aGVsbG8=", "filename": "fake.png"}, + ) + + assert resp.status_code == 400 + assert "unsupported image type" in resp.json()["detail"].lower() + + def test_chat_image_upload_rejects_unknown_profile(self): + resp = self.client.post( + "/api/chat/image-upload?profile=missing-profile", + json={"data_url": "data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAA="}, + ) + + assert resp.status_code == 404 + assert "does not exist" in resp.json()["detail"] + + def test_chat_image_upload_enforces_image_size_cap(self, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.setattr(web_server, "_CHAT_IMAGE_UPLOAD_MAX_BYTES", 4) + + resp = self.client.post( + "/api/chat/image-upload", + json={ + "data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE=", + "filename": "large.png", + }, + ) + + assert resp.status_code == 413 + assert "too large" in resp.json()["detail"].lower() + + def test_chat_image_upload_requires_auth(self): + from hermes_cli.web_server import _SESSION_HEADER_NAME + + resp = self.client.post( + "/api/chat/image-upload", + json={"data_url": "data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAA="}, + headers={_SESSION_HEADER_NAME: "wrong-token"}, + ) + + assert resp.status_code == 401 + # ── Dashboard font override ───────────────────────────────────────── def test_get_dashboard_font_defaults_to_theme(self): @@ -1238,6 +1531,70 @@ class TestWebServerEndpoints: assert captured["list"] == 3 assert captured["count"] == 3 + def _create_session_with_heavy_fields(self, session_id: str) -> None: + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session( + session_id=session_id, + source="cli", + system_prompt="# SOUL.md\n" + ("prompt body " * 500), + model_config={"temperature": 0.7, "notes": "x" * 200}, + ) + finally: + db.close() + + def test_get_sessions_strips_heavy_fields_by_default(self): + """List rows must omit system_prompt/model_config. + + system_prompt is the fully rendered prompt (tens of KB per row) and + dominated the sidebar payload (96% of a 528KB response); no list UI + reads it. Detail reads (GET /api/sessions/{id}) stay complete. + """ + self._create_session_with_heavy_fields("lean-list-row") + + resp = self.client.get("/api/sessions?limit=20&offset=0") + assert resp.status_code == 200 + rows = [s for s in resp.json()["sessions"] if s["id"] == "lean-list-row"] + assert rows, "created session missing from list" + row = rows[0] + assert "system_prompt" not in row + assert "model_config" not in row + # The light fields the sidebar actually renders must survive. + for key in ("id", "source", "started_at", "message_count", "is_active"): + assert key in row + + def test_get_sessions_full_param_keeps_heavy_fields(self): + """?full=1 is the escape hatch for callers that need complete rows.""" + self._create_session_with_heavy_fields("full-list-row") + + resp = self.client.get("/api/sessions?limit=20&offset=0&full=1") + assert resp.status_code == 200 + rows = [s for s in resp.json()["sessions"] if s["id"] == "full-list-row"] + assert rows, "created session missing from list" + row = rows[0] + assert row["system_prompt"].startswith("# SOUL.md") + assert "temperature" in (row["model_config"] or "") + + def test_profiles_sessions_strips_heavy_fields_by_default(self): + """The cross-profile aggregate applies the same list projection.""" + self._create_session_with_heavy_fields("lean-profiles-row") + + resp = self.client.get("/api/profiles/sessions?limit=20&offset=0") + assert resp.status_code == 200 + rows = [s for s in resp.json()["sessions"] if s["id"] == "lean-profiles-row"] + assert rows, "created session missing from profiles list" + row = rows[0] + assert "system_prompt" not in row + assert "model_config" not in row + assert row["profile"] == "default" + + full = self.client.get("/api/profiles/sessions?limit=20&offset=0&full=1") + assert full.status_code == 200 + full_rows = [s for s in full.json()["sessions"] if s["id"] == "lean-profiles-row"] + assert full_rows and full_rows[0]["system_prompt"].startswith("# SOUL.md") + def test_rename_session_updates_title(self): """PATCH /api/sessions/{id} renames a session (regression: the route was missing entirely, so the desktop rename dialog got a 405).""" @@ -1283,6 +1640,112 @@ class TestWebServerEndpoints: resp = self.client.patch("/api/sessions/does-not-exist", json={"title": "x"}) assert resp.status_code == 404 + def test_import_sessions_endpoint_imports_exported_json(self): + from hermes_state import SessionDB + + payload = { + "id": "imported-web-session", + "source": "cli", + "title": "Imported from dashboard", + "started_at": 100.0, + "ended_at": 110.0, + "end_reason": "complete", + "messages": [ + {"role": "user", "content": "hello", "timestamp": 101.0}, + {"role": "assistant", "content": "hi", "timestamp": 102.0}, + ], + } + + resp = self.client.post("/api/sessions/import", json={"sessions": [payload]}) + assert resp.status_code == 200 + data = resp.json() + assert data["imported"] == 1 + assert data["skipped"] == 0 + + db = SessionDB() + try: + session = db.get_session("imported-web-session") + assert session["title"] == "Imported from dashboard" + assert session["message_count"] == 2 + assert [m["content"] for m in db.get_messages("imported-web-session")] == [ + "hello", + "hi", + ] + finally: + db.close() + + duplicate = self.client.post("/api/sessions/import", json={"sessions": [payload]}) + assert duplicate.status_code == 200 + assert duplicate.json()["skipped_ids"] == ["imported-web-session"] + + invalid = self.client.post( + "/api/sessions/import", + json={"sessions": [{"source": "cli", "messages": []}]}, + ) + assert invalid.status_code == 400 + assert invalid.json()["detail"]["errors"] == [ + {"index": 0, "error": "session id is required"} + ] + + def test_import_sessions_endpoint_rejects_oversized_stream(self): + import hermes_cli.web_server as web_server + + payload = b'{"sessions":[]}' + b" " * web_server._SESSION_IMPORT_MAX_BYTES + response = self.client.post( + "/api/sessions/import", + content=payload, + headers={"content-type": "application/json"}, + ) + + assert response.status_code == 413 + assert response.json() == {"detail": "Session import payload is too large"} + + def test_import_sessions_endpoint_rejects_metadata_that_would_break_session_list(self): + invalid = self.client.post( + "/api/sessions/import", + json={ + "sessions": [ + { + "id": "bad-model-config", + "source": "cli", + "model_config": "{not-json", + "messages": [], + } + ] + }, + ) + + assert invalid.status_code == 400 + assert invalid.json()["detail"]["errors"] == [ + { + "index": 0, + "session_id": "bad-model-config", + "error": "model_config must be valid JSON", + } + ] + listed = self.client.get("/api/sessions") + assert listed.status_code == 200 + + @pytest.mark.parametrize( + "message", + [{"content": "missing role"}, {"role": None, "content": "null role"}], + ) + def test_import_sessions_endpoint_rejects_missing_or_null_message_role(self, message): + response = self.client.post( + "/api/sessions/import", + json={"sessions": [{"id": "bad-message-role", "messages": [message]}]}, + ) + + assert response.status_code == 400 + assert response.json()["detail"]["errors"] == [ + { + "index": 0, + "session_id": "bad-message-role", + "error": "messages[0].role must be a non-empty string", + } + ] + assert self.client.get("/api/sessions").status_code == 200 + def test_archive_session_via_patch(self): """PATCH archived=true soft-hides a session; archived=false restores it.""" from hermes_state import SessionDB @@ -1421,6 +1884,29 @@ class TestWebServerEndpoints: assert worker_resp.status_code == 200 assert worker_resp.json()["session_id"] == "worker-tip" + def test_latest_descendant_survives_parent_cycle(self): + """Regression for the #39140 CTE salvage: a corrupted parent chain + that loops (a -> b -> a) must terminate (UNION dedup) instead of + recursing forever like UNION ALL would.""" + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="cyc-a", source="cli") + db.create_session( + session_id="cyc-b", source="cli", parent_session_id="cyc-a" + ) + db._conn.execute( + "UPDATE sessions SET parent_session_id='cyc-b' WHERE id='cyc-a'" + ) + db._conn.commit() + finally: + db.close() + + resp = self.client.get("/api/sessions/cyc-a/latest-descendant") + assert resp.status_code == 200 + assert resp.json()["session_id"] == "cyc-b" + def test_analytics_endpoints_read_requested_profile(self): from hermes_state import SessionDB from hermes_cli import profiles as profiles_mod @@ -1903,6 +2389,36 @@ class TestWebServerEndpoints: "pid": 99, } + def test_action_status_tails_large_log_without_read_text(self, tmp_path, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.setattr(web_server, "_ACTION_LOG_DIR", tmp_path) + web_server._ACTION_PROCS.pop("hermes-update", None) + web_server._ACTION_RESULTS.pop("hermes-update", None) + + log_path = tmp_path / web_server._ACTION_LOG_FILES["hermes-update"] + log_path.write_text( + "stale-start\n" + + ("x" * (web_server._ACTION_LOG_TAIL_MAX_BYTES + 1024)) + + "\ntail-one\ntail-two\n", + encoding="utf-8", + ) + assert log_path.stat().st_size > web_server._ACTION_LOG_TAIL_MAX_BYTES + + original_read_text = Path.read_text + + def fail_if_status_reads_whole_log(path, *args, **kwargs): + if path == log_path: + raise AssertionError("action status must not read the entire log") + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", fail_if_status_reads_whole_log) + + resp = self.client.get("/api/actions/hermes-update/status?lines=3") + + assert resp.status_code == 200 + assert resp.json()["lines"] == ["tail-one", "tail-two"] + def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch): import gateway.config as gateway_config @@ -1916,7 +2432,7 @@ class TestWebServerEndpoints: def get_connected_platforms(self): return [_Platform("telegram")] - monkeypatch.setattr(web_server, "get_running_pid", lambda: 1234) + monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr( web_server, "read_runtime_status", @@ -1948,7 +2464,7 @@ class TestWebServerEndpoints: def get_connected_platforms(self): return [] - monkeypatch.setattr(web_server, "get_running_pid", lambda: None) + monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: None) monkeypatch.setattr( web_server, "read_runtime_status", @@ -2271,7 +2787,7 @@ class TestWebServerEndpoints: assert data["name"] == "backup" assert captured["name"] == "backup" - assert captured["args"] == ["backup", str(archive)] + assert captured["args"] == ["backup", "-o", str(archive)] assert archive.parent == get_hermes_home() / "backups" assert archive.name.startswith("hermes-backup-") assert archive.suffix == ".zip" @@ -2298,7 +2814,7 @@ class TestWebServerEndpoints: archive = Path(resp.json()["archive"]) assert archive.parent == hosted_home / "backups" - assert captured["args"] == ["backup", str(archive)] + assert captured["args"] == ["backup", "-o", str(archive)] assert archive.parent.is_dir() def test_ops_backup_download_streams_dashboard_backup(self, tmp_path): @@ -3580,6 +4096,54 @@ class TestBuildSchemaFromConfig: assert "options" in entry assert "local" in entry["options"] + def test_memory_provider_field_present_as_select(self): + """memory.provider must stay in the config schema. + + Desktop's settings page builds its field list from /api/config/schema — + a key excluded here silently vanishes from Desktop's Memory section + (regression: the dashboard's dedicated memory-provider UI excluded the + key server-side, breaking Desktop's dropdown). The dashboard hides the + field client-side instead. + """ + from hermes_cli.web_server import CONFIG_SCHEMA + entry = CONFIG_SCHEMA["memory.provider"] + assert entry["type"] == "select" + assert entry["category"] == "memory" + options = entry["options"] + # Built-in sentinel first, plus at least one discovered provider. + assert options[0] == "" + assert "builtin" in options + assert len(options) >= 3 + + def test_memory_provider_options_cover_discovered_providers(self): + """Every provider the /api/memory endpoint can activate is selectable.""" + from hermes_cli.web_server import CONFIG_SCHEMA + from plugins.memory import list_memory_provider_names + + options = set(CONFIG_SCHEMA["memory.provider"]["options"]) + missing = set(list_memory_provider_names()) - options + assert missing == set(), f"discovered providers missing from schema options: {missing}" + + def test_approvals_mode_options_match_config_values(self): + """approvals.mode select options must match the values accepted by config.py. + + Previously the dashboard showed ['ask', 'yolo', 'deny'] which are stale + names that don't correspond to any real config value. The correct values + are 'manual', 'smart', and 'off' (see hermes_cli/config.py). + 'smart' was missing entirely, making it unreachable from the UI. + """ + from hermes_cli.web_server import CONFIG_SCHEMA + entry = CONFIG_SCHEMA["approvals.mode"] + assert entry["type"] == "select" + options = entry["options"] + assert "manual" in options, "'manual' missing from approvals.mode options" + assert "smart" in options, "'smart' missing from approvals.mode options" + assert "off" in options, "'off' missing from approvals.mode options" + # Stale names that were previously shown but don't match config values + assert "ask" not in options, "stale option 'ask' should not appear" + assert "yolo" not in options, "stale option 'yolo' should not appear" + assert "deny" not in options, "stale option 'deny' should not appear" + def test_empty_prefix_produces_correct_keys(self): from hermes_cli.web_server import _build_schema_from_config test_config = {"model": "test", "nested": {"key": "val"}} @@ -4186,6 +4750,87 @@ class TestNewEndpoints: finally: reset_hermes_home_override(token) + def test_profiles_create_builder_mcp_auth_is_profile_scoped( + self, monkeypatch + ): + from hermes_constants import get_hermes_home + import hermes_cli.profiles as profiles_mod + + monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None) + + secret = "profile-builder-secret" + resp = self.client.post( + "/api/profiles", + json={ + "name": "builder-auth", + "mcp_servers": [ + { + "name": "Bearer Server", + "url": "https://example.com/mcp", + "auth": "header", + "bearer_token": f"Bearer {secret}", + }, + { + "name": "oauth-server", + "url": "https://example.com/oauth-mcp", + "auth": "oauth", + }, + { + "name": "local-server", + "command": "uvx", + "args": ["mcp-server", "--debug"], + "env": {"API_KEY": "stdio-secret"}, + }, + { + "name": "missing-token", + "url": "https://example.com/bad", + "auth": "header", + }, + { + "name": "http-with-env", + "url": "https://example.com/bad-env", + "env": {"NOT_SUPPORTED": "value"}, + }, + ], + }, + ) + + assert resp.status_code == 200 + assert resp.json()["mcp_written"] == 3 + + root = get_hermes_home() + profile_dir = root / "profiles" / "builder-auth" + config_text = (profile_dir / "config.yaml").read_text(encoding="utf-8") + config = yaml.safe_load(config_text) + servers = config["mcp_servers"] + + assert sorted(servers) == [ + "Bearer Server", + "local-server", + "oauth-server", + ] + assert servers["Bearer Server"] == { + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer ${MCP_BEARER_SERVER_API_KEY}", + }, + } + assert servers["oauth-server"] == { + "url": "https://example.com/oauth-mcp", + "auth": "oauth", + } + assert servers["local-server"] == { + "command": "uvx", + "args": ["mcp-server", "--debug"], + "env": {"API_KEY": "stdio-secret"}, + } + + assert secret not in config_text + profile_env = (profile_dir / ".env").read_text(encoding="utf-8") + assert f"MCP_BEARER_SERVER_API_KEY={secret}" in profile_env + assert "Bearer Bearer" not in profile_env + assert not (root / ".env").exists() + def test_profile_open_terminal_uses_macos_terminal(self, monkeypatch): from hermes_constants import get_hermes_home import hermes_cli.web_server as web_server @@ -5356,7 +6001,7 @@ class TestStatusRemoteGateway: """When local PID check fails and remote probe succeeds, gateway shows running.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: None) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None) monkeypatch.setattr(ws, "read_runtime_status", lambda: None) monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642") monkeypatch.setattr(ws, "_probe_gateway_health", lambda: (True, { @@ -5378,7 +6023,7 @@ class TestStatusRemoteGateway: """When local PID check succeeds, the remote probe is never called.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: 1234) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr(ws, "read_runtime_status", lambda: { "gateway_state": "running", "platforms": {}, @@ -5401,7 +6046,7 @@ class TestStatusRemoteGateway: """When GATEWAY_HEALTH_URL is unset, no probe is attempted.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: None) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None) monkeypatch.setattr(ws, "read_runtime_status", lambda: None) monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", None) @@ -5415,7 +6060,7 @@ class TestStatusRemoteGateway: """Remote gateway running but PID not in response — pid should be None.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: None) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None) monkeypatch.setattr(ws, "read_runtime_status", lambda: None) monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642") monkeypatch.setattr(ws, "_probe_gateway_health", lambda: (True, { @@ -5454,7 +6099,7 @@ class TestGatewayBusyReadout: """gateway_busy is True iff running AND active_agents > 0.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: 1234) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr(ws, "read_runtime_status", lambda: { "gateway_state": "running", "platforms": {}, @@ -5472,7 +6117,7 @@ class TestGatewayBusyReadout: """A running gateway with zero in-flight turns is drainable, not busy.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: 1234) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr(ws, "read_runtime_status", lambda: { "gateway_state": "running", "platforms": {}, @@ -5490,7 +6135,7 @@ class TestGatewayBusyReadout: gate dominates.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: 1234) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr(ws, "read_runtime_status", lambda: { "gateway_state": "draining", "platforms": {}, @@ -5506,7 +6151,7 @@ class TestGatewayBusyReadout: active_agents 0 — never a spurious busy that would wedge NAS.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: None) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None) monkeypatch.setattr(ws, "read_runtime_status", lambda: None) monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", None) @@ -5522,9 +6167,9 @@ class TestGatewayBusyReadout: wins over the file.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: None) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None) monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", None) - # File says running with active turns, but get_running_pid()==None and + # File says running with active turns, but get_running_pid_cached()==None and # get_runtime_status_running_pid finds no live PID → gateway_running False. monkeypatch.setattr(ws, "get_runtime_status_running_pid", lambda *_a, **_k: None) monkeypatch.setattr(ws, "read_runtime_status", lambda: { @@ -5543,7 +6188,7 @@ class TestGatewayBusyReadout: float so NAS can size its poll deadline without out-of-band knowledge.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: 1234) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr(ws, "read_runtime_status", lambda: { "gateway_state": "running", "platforms": {}, @@ -5561,7 +6206,7 @@ class TestGatewayBusyReadout: produce a spurious busy — it degrades to 0/not-busy.""" import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "get_running_pid", lambda: 1234) + monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234) monkeypatch.setattr(ws, "read_runtime_status", lambda: { "gateway_state": "running", "platforms": {}, diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py index f8fa1e008a5f..abcdbb681fa4 100644 --- a/tests/hermes_cli/test_web_server_cron_profiles.py +++ b/tests/hermes_cli/test_web_server_cron_profiles.py @@ -1,5 +1,10 @@ """Regression tests for dashboard cron job profile routing.""" +from concurrent.futures import ThreadPoolExecutor +import json +from queue import Empty, SimpleQueue +import threading + import pytest from fastapi import HTTPException @@ -22,7 +27,16 @@ def isolated_profiles(tmp_path, monkeypatch): return {"default": default_home, "worker_alpha": worker_home} -def test_call_cron_for_profile_routes_storage_and_restores_globals(isolated_profiles): +def _drain_queue(q): + values = [] + while True: + try: + values.append(q.get_nowait()) + except Empty: + return values + + +def test_call_cron_for_profile_routes_storage_without_mutating_globals(isolated_profiles): from cron import jobs as cron_jobs from hermes_cli import web_server @@ -50,6 +64,135 @@ def test_call_cron_for_profile_routes_storage_and_restores_globals(isolated_prof assert cron_jobs.OUTPUT_DIR == old_output_dir +def test_fire_cron_job_scopes_store_and_runtime_home_together( + isolated_profiles, + monkeypatch, +): + """A profile fire must execute and persist under the same profile home.""" + from cron import jobs as cron_jobs + from cron import scheduler + from hermes_cli import web_server + + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + + default_home = isolated_profiles["default"] + worker_home = isolated_profiles["worker_alpha"] + monkeypatch.setattr(scheduler, "_hermes_home", None) + captured = {} + + class RecordingProvider: + def fire_due(self, job_id, *, adapters=None, loop=None): + captured["job_id"] = job_id + captured["runtime_home"] = scheduler._get_hermes_home() + captured["jobs_file"] = cron_jobs._current_cron_store().jobs_file + return True + + monkeypatch.setattr( + "cron.scheduler_provider.resolve_cron_scheduler", + lambda: RecordingProvider(), + ) + + outer_token = set_hermes_home_override(default_home) + try: + assert web_server._fire_cron_job_for_profile("worker_alpha", "worker-job") is True + assert captured == { + "job_id": "worker-job", + "runtime_home": worker_home, + "jobs_file": worker_home / "cron" / "jobs.json", + } + assert scheduler._get_hermes_home() == default_home + finally: + reset_hermes_home_override(outer_token) + + +def test_profile_call_cannot_retarget_ticker_store_mid_write( + isolated_profiles, + monkeypatch, +): + """A dashboard profile call must not redirect a concurrent ticker save.""" + from cron import jobs as cron_jobs + from hermes_cli import web_server + + default_cron = isolated_profiles["default"] / "cron" + worker_cron = isolated_profiles["worker_alpha"] / "cron" + default_file = default_cron / "jobs.json" + worker_file = worker_cron / "jobs.json" + default_job = { + "id": "default-job", + "name": "default job", + "schedule": {"kind": "interval", "minutes": 60}, + "next_run_at": "2026-07-09T00:00:00+00:00", + } + worker_job = { + "id": "worker-job", + "name": "worker job", + "schedule": {"kind": "interval", "minutes": 60}, + "next_run_at": "2026-07-09T00:00:00+00:00", + } + default_file.write_text(json.dumps({"jobs": [default_job]}), encoding="utf-8") + worker_file.write_text(json.dumps({"jobs": [worker_job]}), encoding="utf-8") + + monkeypatch.setattr(cron_jobs, "CRON_DIR", default_cron) + monkeypatch.setattr(cron_jobs, "JOBS_FILE", default_file) + monkeypatch.setattr(cron_jobs, "OUTPUT_DIR", default_cron / "output") + monkeypatch.setattr( + cron_jobs, + "compute_next_run", + lambda _schedule, _last_run_at=None: "2026-07-10T00:00:00+00:00", + ) + + ticker_loaded = threading.Event() + release_ticker = threading.Event() + profile_entered = threading.Event() + ticker_done = threading.Event() + ticker_thread = threading.local() + original_load_jobs = cron_jobs.load_jobs + + def blocking_load_jobs(): + loaded = original_load_jobs() + if getattr(ticker_thread, "active", False): + ticker_loaded.set() + assert release_ticker.wait(5), "profile call did not enter in time" + return loaded + + def hold_profile_call(): + profile_entered.set() + assert ticker_done.wait(5), "ticker did not finish in time" + return True + + def run_ticker_write(): + ticker_thread.active = True + try: + return cron_jobs.advance_next_run("default-job") + finally: + ticker_done.set() + + monkeypatch.setattr(cron_jobs, "load_jobs", blocking_load_jobs) + monkeypatch.setattr(cron_jobs, "_hold_profile_call", hold_profile_call, raising=False) + + with ThreadPoolExecutor(max_workers=2) as pool: + ticker_future = pool.submit(run_ticker_write) + assert ticker_loaded.wait(5), "ticker did not load the default store" + profile_future = pool.submit( + web_server._call_cron_for_profile, + "worker_alpha", + "_hold_profile_call", + ) + assert profile_entered.wait(5), "profile call did not retarget its store" + release_ticker.set() + assert ticker_future.result(timeout=5) is True + assert profile_future.result(timeout=5) is True + + default_saved = json.loads(default_file.read_text(encoding="utf-8"))["jobs"] + worker_saved = json.loads(worker_file.read_text(encoding="utf-8"))["jobs"] + assert [job["id"] for job in worker_saved] == ["worker-job"] + assert [job["id"] for job in default_saved] == ["default-job"] + assert default_saved[0]["next_run_at"] == "2026-07-10T00:00:00+00:00" + + @pytest.mark.asyncio async def test_list_cron_jobs_all_includes_default_and_named_profiles(isolated_profiles): from hermes_cli import web_server @@ -159,6 +302,60 @@ async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_pr assert worker_jobs[0]["enabled"] is False +@pytest.mark.asyncio +async def test_cron_profile_scan_runs_off_event_loop(isolated_profiles, monkeypatch): + from hermes_cli import web_server + + worker_job = web_server._call_cron_for_profile( + "worker_alpha", + "create_job", + prompt="managed by named profile", + schedule="every 1h", + name="thread-offload-job", + ) + + event_loop_thread = threading.get_ident() + profile_scan_threads = SimpleQueue() + worker_threads = SimpleQueue() + original_profile_dicts = web_server._cron_profile_dicts + original_find = web_server._find_cron_job_profile + + def tracking_profile_dicts(): + profile_scan_threads.put(threading.get_ident()) + return original_profile_dicts() + + def tracking_find(job_id): + worker_threads.put(threading.get_ident()) + return original_find(job_id) + + monkeypatch.setattr(web_server, "_cron_profile_dicts", tracking_profile_dicts) + monkeypatch.setattr(web_server, "_find_cron_job_profile", tracking_find) + + jobs = await web_server.list_cron_jobs(profile="all") + paused = await web_server.pause_cron_job(worker_job["id"]) + + assert any(job["id"] == worker_job["id"] for job in jobs) + assert paused["profile"] == "worker_alpha" + profile_scan_thread_ids = _drain_queue(profile_scan_threads) + worker_thread_ids = _drain_queue(worker_threads) + assert profile_scan_thread_ids + assert worker_thread_ids + assert all(thread_id != event_loop_thread for thread_id in profile_scan_thread_ids) + assert all(thread_id != event_loop_thread for thread_id in worker_thread_ids) + + +@pytest.mark.asyncio +async def test_cron_dashboard_io_rejects_async_callables(): + from hermes_cli import web_server + + async def async_callable(): + return "nope" + + with pytest.raises(TypeError, match="only accepts sync callables"): + await web_server._run_cron_dashboard_io(async_callable) + + + @pytest.mark.asyncio async def test_update_cron_job_normalizes_dashboard_core_fields(isolated_profiles, tmp_path): from hermes_cli import web_server diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py index 7cc3ba8351d4..14141a815362 100644 --- a/tests/hermes_cli/test_web_server_profile_unification.py +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -161,6 +161,30 @@ class TestProfileScopedMcp: listing = client.get("/api/mcp/servers").json() assert not any(s["name"] == "scoped-srv" for s in listing["servers"]) + def test_mcp_bearer_secret_is_profile_scoped(self, client, isolated_profiles): + secret = "worker-only-secret" + response = client.post( + "/api/mcp/servers", + params={"profile": "worker_beta"}, + json={ + "name": "profile-bearer", + "url": "https://example.com/mcp", + "auth": "header", + "bearer_token": secret, + }, + ) + + assert response.status_code == 200 + worker_cfg = _cfg(isolated_profiles["worker_beta"]) + assert worker_cfg["mcp_servers"]["profile-bearer"]["headers"] == { + "Authorization": "Bearer ${MCP_PROFILE_BEARER_API_KEY}", + } + assert secret in (isolated_profiles["worker_beta"] / ".env").read_text() + assert not (isolated_profiles["default"] / ".env").exists() + assert "profile-bearer" not in _cfg(isolated_profiles["default"]).get( + "mcp_servers", {} + ) + def test_mcp_enabled_toggle_scoped(self, client, isolated_profiles): (isolated_profiles["worker_beta"] / "config.yaml").write_text( "mcp_servers:\n srv1:\n url: http://x/sse\n", encoding="utf-8" @@ -458,7 +482,9 @@ class TestProfileScopedGateway: return None monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1)) - monkeypatch.setattr(web_server, "get_running_pid", fake_get_running_pid) + # get_status probes via the TTL-cached wrapper (PR #53511 salvage); + # patch the cached name so the fake still intercepts the probe. + monkeypatch.setattr(web_server, "get_running_pid_cached", fake_get_running_pid) monkeypatch.setattr( web_server, "read_runtime_status", @@ -493,7 +519,7 @@ class TestProfileScopedGateway: "updated_at": "2026-06-17T00:00:00+00:00", } monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1)) - monkeypatch.setattr(web_server, "get_running_pid", lambda: None) + monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: None) monkeypatch.setattr(web_server, "read_runtime_status", lambda: runtime) monkeypatch.setattr( web_server, "get_runtime_status_running_pid", lambda payload: 4242 diff --git a/tests/hermes_cli/test_yolo_startup_order.py b/tests/hermes_cli/test_yolo_startup_order.py new file mode 100644 index 000000000000..6d30b776fbd9 --- /dev/null +++ b/tests/hermes_cli/test_yolo_startup_order.py @@ -0,0 +1,77 @@ +"""Regression tests for #60328: --yolo must set HERMES_YOLO_MODE in +main() before _prepare_agent_startup() triggers tool imports. + +The freeze mechanism in tools.approval (_YOLO_MODE_FROZEN) is correct +by design (PR #7994). The bug was that main() set the env var inside +cmd_chat(), which runs *after* _prepare_agent_startup() has already +imported tools.approval and frozen the constant to False. + +These tests verify the ordering in main() itself: the env var must +already be set at the moment _prepare_agent_startup() is called. +If someone moves the assignment back into cmd_chat(), these tests +fail — catching the exact #60328 regression. +""" + +import os +import sys + + +def _run_main_and_capture_yolo_at_startup(monkeypatch, argv): + """Run main() with *argv*, capturing HERMES_YOLO_MODE at the + moment _prepare_agent_startup is called. + + Returns the captured env var value (or None if unset). + """ + yolo_at_startup = {} + + def spy_prepare_startup(args): + yolo_at_startup["value"] = os.environ.get("HERMES_YOLO_MODE") + + monkeypatch.setattr( + "hermes_cli.main._prepare_agent_startup", spy_prepare_startup + ) + # Stub cmd_chat so main() returns cleanly without entering chat. + monkeypatch.setattr("hermes_cli.main.cmd_chat", lambda args: None) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.setattr(sys, "argv", argv) + + from hermes_cli.main import main as cli_main + + cli_main() + + return yolo_at_startup.get("value") + + +def test_top_level_yolo_flag_sets_env_before_startup(monkeypatch): + """hermes --yolo must set HERMES_YOLO_MODE before + _prepare_agent_startup imports tools.approval.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes", "--yolo"] + ) + assert result == "1", ( + "HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was " + "called from main() with --yolo. This is the #60328 regression: " + "the env var is set too late (inside cmd_chat, after tool imports)." + ) + + +def test_chat_subcommand_yolo_flag_sets_env_before_startup(monkeypatch): + """hermes chat --yolo must also set HERMES_YOLO_MODE before + _prepare_agent_startup.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes", "chat", "--yolo"] + ) + assert result == "1", ( + "HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was " + "called from main() with 'chat --yolo'." + ) + + +def test_no_yolo_flag_leaves_env_unset_at_startup(monkeypatch): + """Without --yolo, HERMES_YOLO_MODE must not be set at startup.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes"] + ) + assert result is None, ( + "HERMES_YOLO_MODE was unexpectedly set at startup without --yolo." + ) diff --git a/tests/plugins/image_gen/test_deepinfra_provider.py b/tests/plugins/image_gen/test_deepinfra_provider.py new file mode 100644 index 000000000000..5cd3c0ffcef0 --- /dev/null +++ b/tests/plugins/image_gen/test_deepinfra_provider.py @@ -0,0 +1,129 @@ +"""Tests for the bundled DeepInfra image_gen plugin. + +Invariants only — no snapshots of specific model ids. Most surface-level +contracts (network-failure → empty list, tag filtering, no-model error) +are covered by the shared tag-filter test in +``tests/hermes_cli/test_api_key_providers.py``; these two tests pin the +plugin-specific bits that wrapper doesn't reach. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +import plugins.image_gen.deepinfra as deepinfra_plugin + + +# 1×1 transparent PNG — valid bytes for save_b64_image() +_PNG_HEX = ( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4" + "890000000d49444154789c6300010000000500010d0a2db40000000049454e44" + "ae426082" +) + + +def _b64_png() -> str: + import base64 + + return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode() + + +@pytest.fixture(autouse=True) +def _isolation(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + import hermes_cli.models as _models_mod + monkeypatch.setattr(_models_mod, "_deepinfra_catalog_cache", {}) + monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key") + yield + + +def test_list_models_filters_by_image_gen_tag(monkeypatch): + """Plugin-side wiring: list_models() returns only ``image-gen``-tagged + catalog entries and surfaces pricing + default dims when present.""" + import json + import hermes_cli.models as models + + class _Resp: + def __enter__(self): return self + def __exit__(self, *a): return False + def read(self): + return json.dumps({"data": [ + {"id": "vendor/chat", "metadata": {"tags": ["chat"]}}, + {"id": "vendor/img", "metadata": { + "tags": ["image-gen"], + "pricing": {"per_image_unit": 0.005}, + "default_width": 1024, + }}, + ]}).encode() + + monkeypatch.setattr( + models, "_urlopen_model_catalog_request", lambda *a, **kw: _Resp() + ) + rows = deepinfra_plugin.DeepInfraImageGenProvider().list_models() + ids = {row["id"] for row in rows} + assert ids == {"vendor/img"} + img = next(row for row in rows if row["id"] == "vendor/img") + assert "price" in img and img["default_width"] == 1024 + + +def test_generate_calls_openai_sdk_with_deepinfra_base_url(monkeypatch): + """Happy path: pinned model → openai SDK called with DeepInfra + base_url + Bearer key → b64 saved to cache.""" + monkeypatch.setenv("DEEPINFRA_IMAGE_MODEL", "vendor/test-img") + captured: dict = {} + + class _FakeImages: + def generate(self, **kwargs): + captured["kwargs"] = kwargs + return SimpleNamespace(data=[SimpleNamespace(b64_json=_b64_png(), url=None)]) + + class _FakeClient: + def __init__(self, api_key=None, base_url=None): + captured["api_key"] = api_key + captured["base_url"] = base_url + self.images = _FakeImages() + + fake_openai = MagicMock() + fake_openai.OpenAI = _FakeClient + with patch.dict("sys.modules", {"openai": fake_openai}): + result = deepinfra_plugin.DeepInfraImageGenProvider().generate( + prompt="a cat", aspect_ratio="square", + ) + + assert result["success"] is True + assert "deepinfra" in captured["base_url"] + assert captured["api_key"] == "test-key" + assert captured["kwargs"]["model"] == "vendor/test-img" + + +@pytest.mark.parametrize( + "kwargs", + [ + {"image_url": "https://example.com/source.png"}, + {"reference_image_urls": ["https://example.com/reference.png"]}, + ], +) +def test_generate_rejects_unsupported_edit_inputs_without_calling_sdk( + monkeypatch, kwargs +): + monkeypatch.setenv("DEEPINFRA_IMAGE_MODEL", "vendor/test-img") + fake_openai = MagicMock() + with patch.dict("sys.modules", {"openai": fake_openai}): + result = deepinfra_plugin.DeepInfraImageGenProvider().generate( + prompt="edit this", **kwargs + ) + + assert result["success"] is False + assert result["error_type"] == "modality_unsupported" + assert result["provider"] == "deepinfra" + fake_openai.OpenAI.assert_not_called() + + +def test_capabilities_advertise_text_to_image_only(): + assert deepinfra_plugin.DeepInfraImageGenProvider().capabilities() == { + "modalities": ["text"], + "max_reference_images": 0, + } diff --git a/tests/plugins/image_gen/test_openai_codex_provider.py b/tests/plugins/image_gen/test_openai_codex_provider.py index dc8560c8b3c7..338ea97418a7 100644 --- a/tests/plugins/image_gen/test_openai_codex_provider.py +++ b/tests/plugins/image_gen/test_openai_codex_provider.py @@ -9,6 +9,7 @@ endpoint. from __future__ import annotations import importlib +import json from pathlib import Path import pytest @@ -307,6 +308,91 @@ class TestGenerate: assert result["error_type"] == "api_error" assert "cloudflare 403" in result["error"] + def test_unsupported_image_tool_returns_capability_error(self, provider, monkeypatch): + monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token") + + def _unsupported(*args, **kwargs): + raise codex_plugin.CodexImageGenerationUnsupportedError( + "Tool choice 'image_generation' not found in 'tools' parameter." + ) + + monkeypatch.setattr(codex_plugin, "_collect_image_b64", _unsupported) + + result = provider.generate("a cat") + + assert result["success"] is False + assert result["error_type"] == "capability_unsupported" + assert "current Codex account" in result["error"] + assert "OpenAI API key, FAL, or xAI" in result["error"] + + +class TestCapabilityErrorDetection: + @pytest.mark.parametrize( + "body", + [ + "Tool choice 'image_generation' not found in 'tools' parameter.", + '{"error":{"message":"Tool choice \'image_generation\' not found in \'tools\' parameter."}}', + ], + ) + def test_detects_exact_codex_image_tool_rejection(self, body): + assert codex_plugin._is_image_generation_unsupported_error(400, body) is True + + @pytest.mark.parametrize( + ("status_code", "body"), + [ + (401, "Tool choice 'image_generation' not found in 'tools' parameter."), + (400, "Tool choice 'web_search' not found in 'tools' parameter."), + (400, "The image_generation request was rejected by moderation."), + (500, "Tool choice 'image_generation' not found in 'tools' parameter."), + ], + ) + def test_does_not_misclassify_other_failures(self, status_code, body): + assert codex_plugin._is_image_generation_unsupported_error(status_code, body) is False + + def test_does_not_match_error_message_with_extra_text(self): + body = json.dumps({ + "error": { + "message": ( + "Tool choice 'image_generation' not found in 'tools' parameter " + "because the request is malformed." + ) + } + }) + + assert codex_plugin._is_image_generation_unsupported_error(400, body) is False + + def test_collect_classifies_exact_http_error_after_large_metadata(self, monkeypatch): + import httpx + + body = json.dumps({ + "metadata": "x" * 600, + "error": { + "message": "Tool choice 'image_generation' not found in 'tools' parameter." + }, + }) + + def _handler(request): + return httpx.Response(400, text=body, request=request) + + real_client = httpx.Client + monkeypatch.setattr( + httpx, + "Client", + lambda *args, **kwargs: real_client( + transport=httpx.MockTransport(_handler), + headers=kwargs.get("headers"), + timeout=kwargs.get("timeout"), + ), + ) + + with pytest.raises(codex_plugin.CodexImageGenerationUnsupportedError): + codex_plugin._collect_image_b64( + "codex-token", + prompt="a cat", + size="1024x1024", + quality="low", + ) + # ── Plugin entry point ────────────────────────────────────────────────────── diff --git a/tests/plugins/memory/test_holographic_store.py b/tests/plugins/memory/test_holographic_store.py new file mode 100644 index 000000000000..df351864b001 --- /dev/null +++ b/tests/plugins/memory/test_holographic_store.py @@ -0,0 +1,243 @@ +"""Tests for the holographic MemoryStore shared-connection registry. + +MemoryStore instances pointing at the same database file must share one +process-wide SQLite connection and one re-entrant lock. Multiple providers +coexist in a single process (the main agent plus every delegate_task +subagent); when each instance owned a private connection they raced as +independent WAL writers and intermittently failed with "database is locked". + +Covers: connection sharing/refcounting, close() semantics, cross-instance +visibility, concurrent multi-instance writers, and write-lock release after +a failed write. +""" + +import sqlite3 +import threading + +import pytest + +from plugins.memory.holographic.store import MemoryStore + + +@pytest.fixture(autouse=True) +def _clean_shared_registry(): + """Each test starts and ends with an empty shared-connection registry.""" + # Drop any leakage from earlier tests in the same process. + for entry in list(MemoryStore._shared.values()): + try: + entry["conn"].close() + except sqlite3.Error: + pass + MemoryStore._shared.clear() + yield + leaked = list(MemoryStore._shared) + for entry in list(MemoryStore._shared.values()): + try: + entry["conn"].close() + except sqlite3.Error: + pass + MemoryStore._shared.clear() + assert not leaked, f"test leaked shared connections: {leaked}" + + +@pytest.fixture +def db_path(tmp_path): + return tmp_path / "memory_store.db" + + +class TestSharedConnection: + def test_same_path_shares_one_connection(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + try: + assert a._conn is b._conn + assert a._lock is b._lock + assert len(MemoryStore._shared) == 1 + assert MemoryStore._shared[str(a.db_path)]["refs"] == 2 + finally: + a.close() + b.close() + + def test_different_paths_get_distinct_connections(self, tmp_path): + a = MemoryStore(tmp_path / "one.db") + b = MemoryStore(tmp_path / "two.db") + try: + assert a._conn is not b._conn + assert len(MemoryStore._shared) == 2 + finally: + a.close() + b.close() + + def test_symlinked_path_shares_connection(self, tmp_path): + """A symlink to the same DB file must hit the same registry entry — + otherwise two connections to one file silently reintroduce the + multi-writer contention the registry exists to prevent.""" + real_dir = tmp_path / "real" + real_dir.mkdir() + link_dir = tmp_path / "link" + link_dir.symlink_to(real_dir) + + a = MemoryStore(real_dir / "memory_store.db") + b = MemoryStore(link_dir / "memory_store.db") + try: + assert a._conn is b._conn + assert len(MemoryStore._shared) == 1 + finally: + a.close() + b.close() + + def test_writes_visible_across_instances(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + try: + fact_id = a.add_fact("Hermes likes shared connections", category="test") + facts = b.list_facts(category="test") + assert [f["fact_id"] for f in facts] == [fact_id] + finally: + a.close() + b.close() + + def test_schema_initialised_once_per_connection(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) # must not re-run schema init / WAL probe + try: + assert MemoryStore._shared[str(a.db_path)]["ready"] is True + b.add_fact("schema still works") + finally: + a.close() + b.close() + + +class TestCloseSemantics: + def test_closing_one_instance_keeps_sibling_alive(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + a.close() + try: + # The shared connection must survive the sibling's close(). + fact_id = b.add_fact("survivor write") + assert fact_id > 0 + finally: + b.close() + + def test_last_close_releases_connection(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + conn = a._conn + a.close() + b.close() + assert MemoryStore._shared == {} + with pytest.raises(sqlite3.ProgrammingError): + conn.execute("SELECT 1") + + def test_close_is_idempotent(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + a.close() + a.close() # double close must not steal b's reference + try: + b.add_fact("still alive after double close") + assert MemoryStore._shared[str(b.db_path)]["refs"] == 1 + finally: + b.close() + + def test_context_manager_releases_reference(self, db_path): + with MemoryStore(db_path) as store: + store.add_fact("context managed") + assert MemoryStore._shared == {} + + def test_reopen_after_full_close(self, db_path): + with MemoryStore(db_path) as store: + store.add_fact("first lifetime") + with MemoryStore(db_path) as store: + facts = store.list_facts() + assert [f["content"] for f in facts] == ["first lifetime"] + + +class TestConcurrency: + def test_concurrent_multi_instance_writers(self, db_path): + """Many instances writing from many threads must never hit + 'database is locked' — the failure mode of per-instance connections.""" + n_threads, n_facts = 8, 15 + errors: list[BaseException] = [] + + def writer(idx: int) -> None: + store = MemoryStore(db_path) + try: + for i in range(n_facts): + store.add_fact(f"fact thread={idx} seq={i}", category="load") + except BaseException as exc: # noqa: BLE001 - recorded for assert + errors.append(exc) + finally: + store.close() + + threads = [threading.Thread(target=writer, args=(i,)) for i in range(n_threads)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"concurrent writers failed: {errors[:3]}" + with MemoryStore(db_path) as store: + facts = store.list_facts(category="load", limit=500) + assert len(facts) == n_threads * n_facts + assert MemoryStore._shared == {} + + def test_failed_write_does_not_pin_write_lock(self, db_path, monkeypatch): + """A write that raises mid-method must not leave an open transaction + holding the SQLite write lock (autocommit isolation_level=None).""" + broken = MemoryStore(db_path) + sibling = MemoryStore(db_path) + try: + monkeypatch.setattr( + MemoryStore, + "_rebuild_bank", + lambda self, category: (_ for _ in ()).throw(RuntimeError("boom")), + ) + with pytest.raises(RuntimeError, match="boom"): + broken.add_fact("write that fails after the INSERT") + monkeypatch.undo() + + # No dangling transaction: the connection reports autocommit state + # and the sibling can write immediately. + assert broken._conn.in_transaction is False + sibling.add_fact("sibling write right after the failure") + finally: + broken.close() + sibling.close() + + +class TestProviderShutdown: + """The provider's shutdown() must release its shared connection, not just + drop the reference. Leaving finalization to GC keeps the connection (and + its write lock) alive on a long-running gateway, which is exactly the + "database is locked" contention the shared-connection registry removes.""" + + def test_shutdown_releases_shared_connection(self, db_path): + from plugins.memory.holographic import HolographicMemoryProvider + + provider = HolographicMemoryProvider(config={"db_path": str(db_path)}) + provider.initialize("session-shutdown") + assert MemoryStore._shared[str(db_path)]["refs"] == 1 + + provider.shutdown() + + assert provider._store is None + assert MemoryStore._shared == {} + + def test_shutdown_keeps_sibling_provider_alive(self, db_path): + from plugins.memory.holographic import HolographicMemoryProvider + + a = HolographicMemoryProvider(config={"db_path": str(db_path)}) + b = HolographicMemoryProvider(config={"db_path": str(db_path)}) + a.initialize("session-a") + b.initialize("session-b") + assert MemoryStore._shared[str(db_path)]["refs"] == 2 + + a.shutdown() + # Sibling still holds a live, writable connection. + assert MemoryStore._shared[str(db_path)]["refs"] == 1 + assert b._store is not None + b._store.add_fact("write after sibling shutdown") + b.shutdown() + assert MemoryStore._shared == {} diff --git a/tests/plugins/model_providers/test_custom_profile.py b/tests/plugins/model_providers/test_custom_profile.py index e20ee57b8ceb..152359ed2f30 100644 --- a/tests/plugins/model_providers/test_custom_profile.py +++ b/tests/plugins/model_providers/test_custom_profile.py @@ -49,20 +49,26 @@ class TestCustomReasoningWireShape: assert tl == {} def test_disabled_sends_think_false(self, custom_profile): - """enabled=False → extra_body.think = False (Ollama thinking-off flag).""" + """enabled=False → reasoning_effort='none' top-level + think=False. + + Both fields are required: Ollama's /v1/chat/completions silently + ignores extra_body.think (only /api/chat honours it — ollama#14820) + but respects top-level reasoning_effort (#25758). think=False stays + for proxies and the native /api/chat path. + """ eb, tl = custom_profile.build_api_kwargs_extras( reasoning_config={"enabled": False}, model="glm-5.2" ) assert eb == {"think": False} - assert tl == {} + assert tl == {"reasoning_effort": "none"} def test_effort_none_sends_think_false(self, custom_profile): - """effort='none' is the disable alias → think=False, no effort.""" + """effort='none' is the disable alias → same dual emission.""" eb, tl = custom_profile.build_api_kwargs_extras( reasoning_config={"enabled": True, "effort": "none"}, model="glm-5.2" ) assert eb == {"think": False} - assert tl == {} + assert tl == {"reasoning_effort": "none"} @pytest.mark.parametrize( "effort", ["minimal", "low", "medium", "high", "xhigh", "max"] diff --git a/tests/plugins/model_providers/test_fireworks_profile.py b/tests/plugins/model_providers/test_fireworks_profile.py new file mode 100644 index 000000000000..0a04c7d97f9f --- /dev/null +++ b/tests/plugins/model_providers/test_fireworks_profile.py @@ -0,0 +1,81 @@ +"""Unit tests for the Fireworks AI provider profile. + +Pins the profile's contract without going live: identity, alias registration, +and the pay-as-you-go model defaults (direct catalog ``/models/`` +IDs, not the router-only tier). +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def fireworks_profile(): + """Resolve the registered Fireworks profile through the real discovery path.""" + # Importing model_tools triggers plugin discovery, registering the profile. + import model_tools # noqa: F401 + import providers + + profile = providers.get_provider_profile("fireworks") + assert profile is not None, "fireworks provider profile must be registered" + return profile + + +class TestFireworksIdentity: + def test_core_fields(self, fireworks_profile): + p = fireworks_profile + assert p.name == "fireworks" + assert p.auth_type == "api_key" + assert p.base_url == "https://api.fireworks.ai/inference/v1" + assert "FIREWORKS_API_KEY" in p.env_vars + assert "FIREWORKS_BASE_URL" not in p.env_vars + + def test_display_metadata_present(self, fireworks_profile): + # Prominence copy is surfaced in the picker; keep it non-empty rather + # than pinning exact marketing wording (that's expected to change). + assert fireworks_profile.display_name + assert fireworks_profile.description + assert fireworks_profile.signup_url.startswith("https://") + + +class TestFireworksHeaders: + def test_no_partner_attribution_headers(self, fireworks_profile): + assert "HTTP-Referer" not in fireworks_profile.default_headers + assert "X-Title" not in fireworks_profile.default_headers + + +class TestFireworksAliases: + @pytest.mark.parametrize("alias", ["fireworks-ai", "fw"]) + def test_alias_resolves_via_registry(self, fireworks_profile, alias): + import providers + + resolved = providers.get_provider_profile(alias) + assert resolved is not None + assert resolved.name == "fireworks" + + def test_aliases_declared_on_profile(self, fireworks_profile): + assert "fireworks-ai" in fireworks_profile.aliases + assert "fw" in fireworks_profile.aliases + + +class TestFireworksModelDefaults: + """Defaults must be usable with a standard pay-as-you-go key. + + PAYG keys address ``accounts/fireworks/models/...`` directly; the bundled + defaults target that (the BYOK motion) so a fresh key works out of the box, + and use the standard tier rather than turbo as the out-of-box default. + """ + + def test_aux_model_is_payg_model_not_router(self, fireworks_profile): + aux = fireworks_profile.default_aux_model + assert aux.startswith("accounts/fireworks/models/"), aux + assert "/routers/" not in aux + assert "turbo" not in aux.lower() + + def test_fallback_models_are_payg_models_not_routers(self, fireworks_profile): + assert fireworks_profile.fallback_models, "expected curated fallbacks" + for model in fireworks_profile.fallback_models: + assert model.startswith("accounts/fireworks/models/"), model + assert "/routers/" not in model + assert "turbo" not in model.lower(), model diff --git a/tests/plugins/model_providers/test_ollama_cloud_profile.py b/tests/plugins/model_providers/test_ollama_cloud_profile.py index de1e2be44dad..15e798a2cd67 100644 --- a/tests/plugins/model_providers/test_ollama_cloud_profile.py +++ b/tests/plugins/model_providers/test_ollama_cloud_profile.py @@ -106,9 +106,9 @@ class TestOllamaCloudReasoningEffort: def test_unknown_effort_forwarded(self, ollama_cloud_profile): _, top_level = ollama_cloud_profile.build_api_kwargs_extras( - reasoning_config={"enabled": True, "effort": "ultra"}, + reasoning_config={"enabled": True, "effort": "future-tier"}, ) - assert top_level == {"reasoning_effort": "ultra"} + assert top_level == {"reasoning_effort": "future-tier"} class TestOllamaCloudFullKwargsIntegration: diff --git a/tests/plugins/model_providers/test_upstage_profile.py b/tests/plugins/model_providers/test_upstage_profile.py new file mode 100644 index 000000000000..ce227b675d8f --- /dev/null +++ b/tests/plugins/model_providers/test_upstage_profile.py @@ -0,0 +1,186 @@ +"""Unit tests for the Upstage Solar provider profile. + +Upstage Solar is a plain OpenAI-compatible api-key provider, so this verifies +the profile is registered correctly and wires the expected identity, endpoint, +auth, and catalog fields — the contract every downstream layer (auth, models, +doctor, runtime_provider, transport) reads from. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def upstage_profile(): + """Resolve the registered Upstage profile via the provider registry. + + Importing ``model_tools`` triggers plugin discovery, which registers the + Upstage profile. Going through ``get_provider_profile`` keeps the test + honest about the actual registration path (name + alias resolution). + """ + import model_tools # noqa: F401 + import providers + + profile = providers.get_provider_profile("upstage") + assert profile is not None, "upstage provider profile must be registered" + return profile + + +class TestUpstageProfile: + def test_identity_and_endpoint(self, upstage_profile): + assert upstage_profile.name == "upstage" + assert upstage_profile.api_mode == "chat_completions" + assert upstage_profile.auth_type == "api_key" + assert upstage_profile.base_url == "https://api.upstage.ai/v1" + assert upstage_profile.get_hostname() == "api.upstage.ai" + + def test_solar_alias_resolves(self): + import model_tools # noqa: F401 + import providers + + assert providers.get_provider_profile("solar") is upstage_profile_singleton() + + def test_env_vars(self, upstage_profile): + # API key first, optional base-url override second (priority order). + assert upstage_profile.env_vars == ("UPSTAGE_API_KEY", "UPSTAGE_BASE_URL") + + def test_fallback_models_are_agentic_pro_only(self, upstage_profile): + # Only the agentic, tool-calling Solar Pro models belong in the offline + # catalog — Mini is capable but not agentic, so it's never promoted as a + # default. Live /v1/models still surfaces everything when a key is set. + # Behavior contract (not a frozen list): non-empty, no denied families. + assert upstage_profile.fallback_models + for denied in ("solar-mini", "syn-pro"): + assert not any( + denied in m for m in upstage_profile.fallback_models + ), f"non-agentic family {denied!r} must not be a fallback default" + + def test_default_model_is_solar_pro3(self, upstage_profile): + # Entry [0] is the setup default (get_default_model_for_provider). + assert upstage_profile.fallback_models[0] == "solar-pro3" + + def test_aux_model_left_empty(self, upstage_profile): + # Unset → auxiliary side tasks fall back to the user's main model. + assert upstage_profile.default_aux_model == "" + + +class TestUpstageReasoning: + """``build_api_kwargs_extras`` wires Solar's top-level ``reasoning_effort``. + + Solar Pro accepts ``reasoning_effort`` (minimal|low|medium|high, default + minimal=off) and never requires echoing ``reasoning_content`` back, so only + the request field is emitted — always top-level, never in extra_body. + """ + + @pytest.mark.parametrize("effort", ["low", "medium", "high"]) + def test_pro_explicit_effort_passes_through(self, upstage_profile, effort): + extra_body, top_level = upstage_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": effort}, model="solar-pro3" + ) + assert extra_body == {} + assert top_level == {"reasoning_effort": effort} + + @pytest.mark.parametrize("effort", ["xhigh", "max", "ultra"]) + def test_pro_strong_efforts_collapse_to_high(self, upstage_profile, effort): + _, top_level = upstage_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": effort}, model="solar-pro2" + ) + assert top_level == {"reasoning_effort": "high"} + + def test_unknown_future_effort_collapses_to_high(self, upstage_profile): + # Guard against the #62650 recurrence: a future effort level Hermes + # adds above "high" must collapse to Solar's strongest, not silently + # downgrade to the medium default. + _, top_level = upstage_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "hyperthink"}, + model="solar-pro3", + ) + assert top_level == {"reasoning_effort": "high"} + + def test_pro_enabled_without_effort_defaults_on(self, upstage_profile): + _, top_level = upstage_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True}, model="solar-pro3" + ) + assert top_level == {"reasoning_effort": "medium"} + + def test_pro_minimal_effort_is_omitted(self, upstage_profile): + # Explicit minimal == reasoning off → omit so Solar applies its default. + _, top_level = upstage_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "minimal"}, model="solar-pro3" + ) + assert top_level == {} + + def test_disabled_omits_field(self, upstage_profile): + # `/reasoning none` → enabled False → explicitly off. + _, top_level = upstage_profile.build_api_kwargs_extras( + reasoning_config={"enabled": False, "effort": "high"}, model="solar-pro3" + ) + assert top_level == {} + + @pytest.mark.parametrize("model", ["solar-pro3", "solar-pro", "solar-open2"]) + def test_no_config_defaults_reasoning_on(self, upstage_profile, model): + # Unset reasoning_config → default ON at medium (matches the /reasoning + # "medium (default)" label), not Solar's server default of minimal/off. + _, top_level = upstage_profile.build_api_kwargs_extras(model=model) + assert top_level == {"reasoning_effort": "medium"} + + @pytest.mark.parametrize("model", ["solar-mini", "solar-mini-202610", "syn-pro"]) + def test_no_config_deny_listed_still_omits(self, upstage_profile, model): + # Default-on must not leak to the deny-listed non-reasoning models. + _, top_level = upstage_profile.build_api_kwargs_extras(model=model) + assert top_level == {} + + @pytest.mark.parametrize( + "model", + [ + "solar-pro3-250127", + "solar-open", + "solar-open-250127", + "solar-open2", + "solar-open2-260528", + ], + ) + def test_pro_and_open_variants_support_reasoning(self, upstage_profile, model): + # Both the Solar Pro and Solar Open families (incl. dated variants) + # accept reasoning_effort. + _, top_level = upstage_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "high"}, model=model + ) + assert top_level == {"reasoning_effort": "high"} + + @pytest.mark.parametrize("model", ["solar-mini", "solar-mini-202610", "syn-pro"]) + def test_deny_listed_models_never_send_reasoning(self, upstage_profile, model): + # solar-mini / syn-pro ignore reasoning_effort, so never send it — + # even when the user explicitly enables reasoning. + extra_body, top_level = upstage_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "high"}, model=model + ) + assert extra_body == {} + assert top_level == {} + + @pytest.mark.parametrize("model", ["solar-future", "solar-future-260601"]) + def test_unknown_future_models_default_to_reasoning(self, upstage_profile, model): + # Deny-list semantics: a future Solar model we've never heard of is + # assumed reasoning-capable, so reasoning_effort is sent instead of + # being silently dropped (the old allow-list failure mode). + _, top_level = upstage_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "high"}, model=model + ) + assert top_level == {"reasoning_effort": "high"} + + # And the unset-config default-on path applies to it too. + _, top_level = upstage_profile.build_api_kwargs_extras(model=model) + assert top_level == {"reasoning_effort": "medium"} + + def test_none_model_defaults_to_reasoning(self, upstage_profile): + # No model in context → treated as reasoning-capable, consistent with + # the provider default (fallback_models[0] == "solar-pro3"). + _, top_level = upstage_profile.build_api_kwargs_extras(model=None) + assert top_level == {"reasoning_effort": "medium"} + + +def upstage_profile_singleton(): + import providers + + return providers.get_provider_profile("upstage") diff --git a/tests/plugins/test_chronos_verify.py b/tests/plugins/test_chronos_verify.py index 5747a81721ef..3f8b64ab1929 100644 --- a/tests/plugins/test_chronos_verify.py +++ b/tests/plugins/test_chronos_verify.py @@ -152,6 +152,7 @@ def test_empty_token_refused(rsa_keys): def test_jwks_url_path_resolves_key(rsa_keys, monkeypatch): """The JWKS-URL branch resolves the signing key via PyJWKClient.""" + from plugins.cron_providers.chronos import verify as verify_mod from plugins.cron_providers.chronos.verify import verify_nas_fire_token priv, pub = rsa_keys @@ -168,6 +169,8 @@ def test_jwks_url_path_resolves_key(rsa_keys, monkeypatch): return FakeKey() monkeypatch.setattr("jwt.PyJWKClient", FakeJWKClient) + # Isolate from the process-wide client cache (other tests may have populated it). + monkeypatch.setattr(verify_mod, "_JWK_CLIENTS", {}) claims = verify_nas_fire_token( token=token, expected_audience=AUD, jwks_or_key="https://portal.nousresearch.com/.well-known/jwks.json", @@ -176,6 +179,56 @@ def test_jwks_url_path_resolves_key(rsa_keys, monkeypatch): assert claims is not None and claims["purpose"] == "cron_fire" +def test_jwks_client_is_cached_across_calls(rsa_keys, monkeypatch): + """Regression (Chronos relay 403/401 + 504 storm): the JWKS client must be + constructed ONCE per URL and reused across fires, NOT rebuilt per call. + + A fresh PyJWKClient per fire discards its key cache and forces a synchronous + JWKS HTTP GET on every fire; a burst of concurrent fires then fans out into N + simultaneous fetches that the portal rate-limits (403 → agent 401) or that + block the event loop past the relay's 30s timeout (504). Reusing one cached + client keeps the steady state at zero fetches per fire. This test fails + against the pre-fix code (construct_count == N) and passes with the cache + (construct_count == 1). + """ + from plugins.cron_providers.chronos import verify as verify_mod + from plugins.cron_providers.chronos.verify import verify_nas_fire_token + + priv, pub = rsa_keys + url = "https://portal.nousresearch.com/.well-known/jwks.json" + + counters = {"construct": 0, "fetch": 0} + + class FakeKey: + key = pub + + class CountingJWKClient: + def __init__(self, u): + counters["construct"] += 1 + + def get_signing_key_from_jwt(self, tok): + counters["fetch"] += 1 + return FakeKey() + + monkeypatch.setattr("jwt.PyJWKClient", CountingJWKClient) + # Start from an empty cache so this test's count is deterministic. + monkeypatch.setattr(verify_mod, "_JWK_CLIENTS", {}) + + for _ in range(5): + token = _mint(priv, _base_claims()) + claims = verify_nas_fire_token( + token=token, expected_audience=AUD, jwks_or_key=url, issuer=ISS, + ) + assert claims is not None + + # The client is built once and reused; only the fetch (served from the + # client's own cache in production) is per-call. + assert counters["construct"] == 1, ( + f"expected 1 PyJWKClient construction, got {counters['construct']} " + "(a fresh client per fire is the bug this guards against)" + ) + + def test_get_fire_verifier_returns_nas_verifier(): from plugins.cron_providers.chronos.verify import get_fire_verifier, verify_nas_fire_token diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 9833ea210698..2cf207d7d91c 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -9,6 +9,7 @@ from __future__ import annotations import importlib.util import os +import subprocess import sys import time from pathlib import Path @@ -114,6 +115,102 @@ def test_create_task_appears_on_board(client): assert "researcher" in data["assignees"] +def test_board_list_recommends_persistent_workspace_for_configured_workdir( + client, tmp_path +): + """Board metadata should tell the UI which safe task default to use.""" + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + kb.write_board_metadata("default", default_workdir=str(repo)) + + plain_dir = tmp_path / "notes" + plain_dir.mkdir() + kb.create_board("notes", default_workdir=str(plain_dir)) + kb.create_board("disposable") + + response = client.get("/api/plugins/kanban/boards") + + assert response.status_code == 200 + boards = {board["slug"]: board for board in response.json()["boards"]} + assert boards["default"]["default_workspace_kind"] == "worktree" + assert boards["notes"]["default_workspace_kind"] == "dir" + assert boards["disposable"]["default_workspace_kind"] == "scratch" + + +def test_create_board_persists_project_directory(client, tmp_path): + """The dashboard board form should anchor future tasks to its project.""" + project_dir = tmp_path / "project" + project_dir.mkdir() + + response = client.post( + "/api/plugins/kanban/boards", + json={ + "slug": "project-board", + "name": "Project Board", + "default_workdir": str(project_dir), + }, + ) + + assert response.status_code == 200, response.text + board = response.json()["board"] + assert board["default_workdir"] == str(project_dir.resolve()) + assert board["default_workspace_kind"] == "dir" + assert kb.read_board_metadata("project-board")["default_workdir"] == str( + project_dir.resolve() + ) + + +@pytest.mark.parametrize("path", ["relative/project", "~/missing-project"]) +def test_create_board_rejects_invalid_project_directory(client, path): + """A board must not persist a path that cannot anchor worker output.""" + response = client.post( + "/api/plugins/kanban/boards", + json={"slug": "invalid-project", "default_workdir": path}, + ) + + assert response.status_code == 400 + assert "project directory" in response.json()["detail"].lower() + + +def test_new_board_dialog_collects_project_directory(): + """Board creation should expose the setting that controls safe task defaults.""" + bundle = ( + Path(__file__).resolve().parents[2] + / "plugins" + / "kanban" + / "dashboard" + / "dist" + / "index.js" + ).read_text(encoding="utf-8") + + assert 'const [projectDirectory, setProjectDirectory] = useState("");' in bundle + assert "Project directory" in bundle + assert "Absolute path to the project folder" in bundle + assert "default_workdir: projectDirectory.trim() || undefined" in bundle + + +def test_dashboard_workspace_picker_explains_persistence_contract(): + """Task creation must make scratch deletion visible without a hover.""" + bundle = ( + Path(__file__).resolve().parents[2] + / "plugins" + / "kanban" + / "dashboard" + / "dist" + / "index.js" + ).read_text(encoding="utf-8") + + assert "Temporary — deleted on completion" in bundle + assert "Git worktree — preserved" in bundle + assert "Directory — preserved" in bundle + assert "defaultWorkspacePath: (props.boardMeta && props.boardMeta.default_workdir) || \"\"" in bundle + assert ( + "This workspace and any files left in it are deleted when the task completes." + in bundle + ) + + def test_scheduled_tasks_have_their_own_column_not_todo(client): """Scheduled/time-delay tasks must not be silently bucketed into todo.""" @@ -2085,13 +2182,10 @@ def _patch_specifier_response(monkeypatch, *, content, model="test-model"): resp = MagicMock() resp.choices = [MagicMock()] resp.choices[0].message.content = content - fake_client = MagicMock() - fake_client.chat.completions.create = MagicMock(return_value=resp) - monkeypatch.setattr( - "agent.auxiliary_client.get_text_auxiliary_client", - lambda *a, **kw: (fake_client, model), - ) - return fake_client + # specify_task routes through call_llm now (#35566) — mock it directly. + fake_call = MagicMock(return_value=resp) + monkeypatch.setattr("agent.auxiliary_client.call_llm", fake_call) + return fake_call def test_specify_happy_path(client, monkeypatch): @@ -2153,11 +2247,11 @@ def test_specify_no_aux_client_surfaces_reason(client, monkeypatch): json={"title": "rough", "triage": True}, ).json()["task"] - # Simulate "no auxiliary client configured". - monkeypatch.setattr( - "agent.auxiliary_client.get_text_auxiliary_client", - lambda *a, **kw: (None, ""), - ) + # Simulate "no auxiliary client configured" — call_llm raises when + # no provider resolves (#35566 routing). + def _no_provider(**kwargs): + raise RuntimeError("No LLM provider configured") + monkeypatch.setattr("agent.auxiliary_client.call_llm", _no_provider) r = client.post( f"/api/plugins/kanban/tasks/{t['id']}/specify", @@ -2166,7 +2260,8 @@ def test_specify_no_aux_client_surfaces_reason(client, monkeypatch): assert r.status_code == 200 body = r.json() assert body["ok"] is False - assert "auxiliary client" in body["reason"] + # call_llm's no-provider RuntimeError surfaces via the LLM-error branch. + assert "LLM error" in body["reason"] # Task must stay in triage — nothing was touched. detail = client.get(f"/api/plugins/kanban/tasks/{t['id']}").json()["task"] @@ -2265,3 +2360,127 @@ def test_dashboard_failed_card_highlight_class_exists(): assert "hermes-kanban-card--failed" in js assert "hermes-kanban-card--failed" in css assert "failedIds" in js + +# --------------------------------------------------------------------------- +# Final result visibility for Done cards +# --------------------------------------------------------------------------- + + +def test_task_detail_exposes_result_and_latest_summary_separately(client): + """The drawer receives both source fields without a duplicate alias.""" + r = client.post( + "/api/plugins/kanban/tasks", + json={"title": "Task with explicit result"}, + ) + task_id = r.json()["task"]["id"] + client.patch( + f"/api/plugins/kanban/tasks/{task_id}", + json={"status": "done", "result": "The final answer is 42.", "summary": "short handoff"}, + ) + r = client.get(f"/api/plugins/kanban/tasks/{task_id}") + assert r.status_code == 200 + data = r.json()["task"] + assert data["result"] == "The final answer is 42." + assert data["latest_summary"] == "short handoff" + assert "final_result" not in data + + +def test_task_detail_exposes_latest_summary_when_result_is_empty(client): + """Summary-only completions remain available to the drawer fallback.""" + conn = kb.connect() + task_id = kb.create_task(conn, title="Task with only run summary") + kb.claim_task(conn, task_id) + kb.complete_task(conn, task_id, summary="Report written to /output/report.md") + conn.close() + + r = client.get(f"/api/plugins/kanban/tasks/{task_id}") + assert r.status_code == 200 + data = r.json()["task"] + assert data["status"] == "done" + assert not data["result"] + assert data["latest_summary"] == "Report written to /output/report.md" + + +def test_task_detail_latest_summary_none_when_nothing_recorded(client): + """When no run summary exists, the existing field remains None.""" + r = client.post( + "/api/plugins/kanban/tasks", + json={"title": "Task with no result at all"}, + ) + task_id = r.json()["task"]["id"] + r = client.get(f"/api/plugins/kanban/tasks/{task_id}") + assert r.status_code == 200 + assert r.json()["task"]["latest_summary"] is None + + +def test_board_tasks_include_latest_summary(client): + """Board cards already expose the summary used by the drawer fallback.""" + conn = kb.connect() + task_id = kb.create_task(conn, title="Board card with summary only") + kb.claim_task(conn, task_id) + kb.complete_task(conn, task_id, summary="Done: see attachment") + conn.close() + + r = client.get("/api/plugins/kanban/board") + assert r.status_code == 200 + done_col = next(c for c in r.json()["columns"] if c["name"] == "done") + card = next((t for t in done_col["tasks"] if t["id"] == task_id), None) + assert card is not None + assert "Done: see attachment" in card["latest_summary"] + + +def test_dashboard_done_final_result_section_rendered_from_summary(): + """Frontend must render Final Result section from run summary when task.result is empty.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + assert "t.result || t.latest_summary" in dist + assert "Final Result (run summary)" in dist + assert "No final result was recorded" in dist + assert "orchestrator" in dist or "parent task" in dist + + +def test_task_detail_includes_child_result_summaries(client): + """Parent drawers should receive the child results they need to render.""" + with kb.connect() as conn: + parent = kb.create_task(conn, title="Research topic") + child = kb.create_task(conn, title="Collect sources") + kb.link_tasks(conn, parent, child) + kb.complete_task(conn, parent, summary="Delegated research to child tasks.") + kb.recompute_ready(conn) + kb.complete_task(conn, child, summary="Collected five primary sources.") + + response = client.get(f"/api/plugins/kanban/tasks/{parent}") + + assert response.status_code == 200 + assert response.json()["child_results"] == [ + { + "id": child, + "title": "Collect sources", + "status": "done", + "latest_summary": "Collected five primary sources.", + "result": None, + } + ] + + +def test_dashboard_final_result_uses_existing_fields_without_alias(): + """The drawer should not duplicate result/summary into another API field.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + api = (repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py").read_text() + + assert "var finalResult = t.result || t.latest_summary || null;" in dist + assert "t.final_result" not in dist + assert 'd["final_result"]' not in api + + +def test_dashboard_parent_notice_and_child_results_use_detail_links(): + """Parent detection must use links.children, which exists in task detail.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + detail = dist[dist.index("function TaskDetail"):] + + assert "links.children.length > 0" in detail + assert "t.link_counts" not in detail + assert "Child Results" in detail + assert "props.data.child_results" in detail diff --git a/tests/plugins/test_nemo_relay_plugin.py b/tests/plugins/test_nemo_relay_plugin.py index 1e72520d2991..61958194e585 100644 --- a/tests/plugins/test_nemo_relay_plugin.py +++ b/tests/plugins/test_nemo_relay_plugin.py @@ -41,7 +41,12 @@ class _FakeNemoRelay: call_end=self._tool_call_end, execute=self._tool_execute, ) - self.plugin = SimpleNamespace(initialize=self._plugin_initialize, clear=self._plugin_clear) + self.plugin = SimpleNamespace( + initialize=self._plugin_initialize, + clear=self._plugin_clear, + activate_dynamic_plugins=self._plugin_activate_dynamic, + ) + self.subscribers = SimpleNamespace(flush=self._flush_subscribers) self.LLMRequest = _FakeLLMRequest self.AtofExporterConfig = _FakeAtofExporterConfig self.AtofExporterMode = SimpleNamespace(Append="append", Overwrite="overwrite") @@ -100,6 +105,22 @@ class _FakeNemoRelay: async def _plugin_clear(self): self.events.append(("plugin.clear",)) + async def _plugin_activate_dynamic(self, config, dynamic_plugins): + self.events.append(("plugin.activate_dynamic", config, dynamic_plugins)) + return _FakePluginActivation(self.events) + + def _flush_subscribers(self): + self.events.append(("subscribers.flush",)) + + +class _FakePluginActivation: + def __init__(self, events): + self.events = events + self.report = {"diagnostics": []} + + async def close(self): + self.events.append(("plugin.activation.close",)) + class _FakeLLMRequest: def __init__(self, headers, content): @@ -143,6 +164,7 @@ class _FakeAtifExporter: return True def export_json(self): + self.events.append(("atif.export", self.session_id)) return json.dumps({"session_id": self.session_id, "agent_name": self.agent_name}) @@ -181,6 +203,26 @@ mode = "observe_only" monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) +def _enable_dynamic_plugin(tmp_path, monkeypatch) -> Path: + plugins_toml = tmp_path / "plugins.toml" + plugins_toml.write_text( + f""" +version = 1 + +[[dynamic_plugins]] +plugin_id = "fixture" +kind = "rust_dynamic" +manifest_ref = "{(tmp_path / "fixture" / "relay-plugin.toml").as_posix()}" + +[dynamic_plugins.config] +mode = "test" +""", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) + return plugins_toml + + def test_manifest_fields(): data = yaml.safe_load((PLUGIN_DIR / "plugin.yaml").read_text()) assert data["name"] == "nemo_relay" @@ -508,6 +550,432 @@ enabled = true assert event_names.count("plugin.clear") == 1 +def test_nemo_relay_plugin_activates_and_owns_dynamic_plugins(tmp_path, monkeypatch): + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + _enable_dynamic_plugin(tmp_path, monkeypatch) + monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") + monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif")) + + plugin.on_session_start(session_id="s1") + runtime = plugin._get_runtime() + assert runtime is not None + assert runtime._plugin_activation is not None + llm_result = plugin.on_llm_execution_middleware( + session_id="s1", + provider="openai", + model="fixture", + request={"messages": []}, + next_call=lambda request: {"request": request}, + ) + tool_result = plugin.on_tool_execution_middleware( + session_id="s1", + tool_name="fixture-tool", + args={"value": 1}, + next_call=lambda args: {"args": args}, + ) + assert llm_result["request"]["intercepted"] is True + assert tool_result["args"]["intercepted"] is True + plugin.on_session_finalize(session_id="s1", reason="shutdown") + assert runtime._plugin_activation is not None + assert not any(event[0] == "plugin.activation.close" for event in fake.events) + plugin.on_session_start(session_id="s2") + plugin.on_session_finalize(session_id="s2", reason="shutdown") + assert sum(event[0] == "plugin.activate_dynamic" for event in fake.events) == 1 + + activation = next(event for event in fake.events if event[0] == "plugin.activate_dynamic") + assert "dynamic_plugins" not in activation[1] + assert activation[2] == [ + { + "plugin_id": "fixture", + "kind": "rust_dynamic", + "manifest_ref": str(tmp_path / "fixture" / "relay-plugin.toml"), + "config": {"mode": "test"}, + } + ] + event_names = [event[0] for event in fake.events] + assert "plugin.clear" not in event_names + assert event_names.index("subscribers.flush") < event_names.index("atif.export") + assert event_names.index("atif.export") < event_names.index("atif.deregister") + + runtime.shutdown() + event_names = [event[0] for event in fake.events] + assert event_names.index("atif.deregister") < event_names.index("plugin.activation.close") + + +def test_nemo_relay_rejects_gateway_dynamic_config_with_actionable_diagnostic( + tmp_path, monkeypatch, caplog +): + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + plugins_toml = tmp_path / "plugins.toml" + plugins_toml.write_text( + """ +version = 1 + +[[plugins.dynamic]] +manifest = "plugins/fixture/relay-plugin.toml" + +[plugins.dynamic.config] +mode = "test" +""", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) + + with caplog.at_level("ERROR"): + plugin.on_session_start(session_id="s1") + + assert not any(event[0] == "plugin.activate_dynamic" for event in fake.events) + initialize = next(event for event in fake.events if event[0] == "plugin.initialize") + assert initialize[1] == {"version": 1} + assert "does not expose the CLI lifecycle resolver" in caplog.text + assert "Use Hermes-owned [[dynamic_plugins]]" in caplog.text + + +def test_nemo_relay_explicit_dynamic_paths_resolve_from_plugins_toml(tmp_path, monkeypatch): + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + config_dir = tmp_path / "config" + config_dir.mkdir() + plugins_toml = config_dir / "plugins.toml" + plugins_toml.write_text( + """ +version = 1 + +[[dynamic_plugins]] +plugin_id = "worker-fixture" +kind = "worker" +manifest_ref = "../plugins/worker/relay-plugin.toml" +environment_ref = "../environments/worker-fixture" +""", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) + + plugin.on_session_start(session_id="s1") + + activation = next(event for event in fake.events if event[0] == "plugin.activate_dynamic") + assert activation[2] == [ + { + "plugin_id": "worker-fixture", + "kind": "worker", + "manifest_ref": str(tmp_path / "plugins" / "worker" / "relay-plugin.toml"), + "environment_ref": str(tmp_path / "environments" / "worker-fixture"), + "config": {}, + } + ] + runtime = plugin._get_runtime() + assert runtime is not None + runtime.shutdown() + + +@pytest.mark.parametrize( + ("provider", "api_mode", "expected_surface", "should_rewrite"), + [ + ("custom", "chat_completions", "openai.chat_completions", True), + ("openai-codex", "codex_responses", "openai.responses", True), + ("anthropic", "anthropic_messages", "anthropic.messages", True), + ("custom", "anthropic_messages", "anthropic.messages", True), + ("bedrock", "bedrock_converse", "bedrock", False), + ], +) +def test_nemo_relay_managed_llm_uses_wire_protocol_for_interceptor_dispatch( + tmp_path, + monkeypatch, + provider, + api_mode, + expected_surface, + should_rewrite, +): + fake = _FakeNemoRelay() + supported_surfaces = { + "anthropic.messages", + "openai.chat_completions", + "openai.responses", + } + + def execute(name, request, func, **kwargs): + fake.events.append(("llm.execute.start", name, request.content, kwargs)) + content = dict(request.content) + if name in supported_surfaces: + content["rewritten_for"] = name + result = func(_FakeLLMRequest(request.headers, content)) + fake.events.append(("llm.execute.end", name, result, kwargs)) + return result + + fake.llm.execute = execute + plugin = _fresh_plugin(monkeypatch, fake) + _enable_dynamic_plugin(tmp_path, monkeypatch) + + result = plugin.on_llm_execution_middleware( + session_id="s1", + provider=provider, + api_mode=api_mode, + model="fixture", + request={"messages": [{"role": "user", "content": "hi"}]}, + next_call=lambda request: request, + ) + + execute_start = next( + event for event in fake.events if event[0] == "llm.execute.start" + ) + assert execute_start[1] == expected_surface + assert execute_start[3]["metadata"]["provider"] == provider + assert execute_start[3]["metadata"]["api_mode"] == api_mode + if should_rewrite: + assert result["rewritten_for"] == expected_surface + else: + assert "rewritten_for" not in result + + +def test_nemo_relay_managed_llm_returns_post_next_interceptor_result(tmp_path, monkeypatch): + fake = _FakeNemoRelay() + raw_response = SimpleNamespace( + model="fixture", + choices=[ + SimpleNamespace( + message=SimpleNamespace(role="assistant", content="raw", tool_calls=[]), + finish_reason="stop", + ) + ], + usage=None, + ) + + def execute(name, request, func, **kwargs): + del name, kwargs + normalized = func(_FakeLLMRequest(request.headers, request.content)) + return {**normalized, "post_next_interceptor": True} + + fake.llm.execute = execute + plugin = _fresh_plugin(monkeypatch, fake) + _enable_dynamic_plugin(tmp_path, monkeypatch) + + result = plugin.on_llm_execution_middleware( + session_id="s1", + provider="openai", + api_mode="chat_completions", + model="fixture", + request={"messages": []}, + next_call=lambda request: raw_response, + ) + + assert result["post_next_interceptor"] is True + assert result["assistant_message"]["content"] == "raw" + assert result is not raw_response + + +def test_nemo_relay_managed_tool_returns_post_interceptor_result(tmp_path, monkeypatch): + fake = _FakeNemoRelay() + + def execute(name, args, func, **kwargs): + fake.events.append(("tool.execute.start", name, args, kwargs)) + raw = func({"intercepted": True, **args}) + result = {"compressed": True, "raw": raw} + fake.events.append(("tool.execute.end", name, result, kwargs)) + return result + + fake.tools.execute = execute + plugin = _fresh_plugin(monkeypatch, fake) + _enable_dynamic_plugin(tmp_path, monkeypatch) + + result = plugin.on_tool_execution_middleware( + session_id="s1", + tool_name="fixture-tool", + args={"value": 1}, + next_call=lambda args: {"tool_output": args}, + ) + + assert result == { + "compressed": True, + "raw": {"tool_output": {"intercepted": True, "value": 1}}, + } + + +def test_nemo_relay_plugin_activates_before_registering_managed_middleware(tmp_path, monkeypatch): + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + _enable_dynamic_plugin(tmp_path, monkeypatch) + + class _Context: + def register_hook(self, name, callback): + del name, callback + + def register_middleware(self, name, callback): + del callback + fake.events.append(("hermes.register_middleware", name)) + + plugin.register(_Context()) + + event_names = [event[0] for event in fake.events] + assert event_names.index("plugin.activate_dynamic") < event_names.index( + "hermes.register_middleware" + ) + runtime = plugin._get_runtime() + assert runtime is not None + runtime.shutdown() + + +def test_nemo_relay_plugin_degrades_to_static_config_on_relay_0_5( + tmp_path, monkeypatch, caplog +): + fake = _FakeNemoRelay() + delattr(fake.plugin, "activate_dynamic_plugins") + plugin = _fresh_plugin(monkeypatch, fake) + _enable_dynamic_plugin(tmp_path, monkeypatch) + + with caplog.at_level("WARNING"): + plugin.on_session_start(session_id="s1") + + initialize = next(event for event in fake.events if event[0] == "plugin.initialize") + assert "dynamic_plugins" not in initialize[1] + assert not any(event[0] == "plugin.activate_dynamic" for event in fake.events) + assert "available in NeMo Relay 0.6+" in caplog.text + + +def test_nemo_relay_plugin_rejects_invalid_dynamic_specs(tmp_path, monkeypatch, caplog): + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + plugins_toml = tmp_path / "plugins.toml" + plugins_toml.write_text( + """ +version = 1 +dynamic_plugins = [{ kind = "rust_dynamic", manifest_ref = "missing-id" }] +""", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) + + with caplog.at_level("WARNING"): + plugin.on_session_start(session_id="s1") + + assert not any(event[0] == "plugin.activate_dynamic" for event in fake.events) + assert "plugin_id is required" in caplog.text + + +def test_nemo_relay_plugin_rejects_entire_mixed_valid_invalid_dynamic_request( + tmp_path, monkeypatch, caplog +): + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + plugins_toml = tmp_path / "plugins.toml" + plugins_toml.write_text( + f""" +version = 1 + +[[dynamic_plugins]] +plugin_id = "valid-fixture" +kind = "rust_dynamic" +manifest_ref = "{(tmp_path / "valid" / "relay-plugin.toml").as_posix()}" + +[[dynamic_plugins]] +kind = "worker" +manifest_ref = "{(tmp_path / "invalid" / "relay-plugin.toml").as_posix()}" +""", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) + + with caplog.at_level("WARNING"): + plugin.on_session_start(session_id="s1") + + assert not any(event[0] == "plugin.activate_dynamic" for event in fake.events) + initialize = next(event for event in fake.events if event[0] == "plugin.initialize") + assert "dynamic_plugins" not in initialize[1] + assert "no dynamic plugins will be activated" in caplog.text + + +def test_nemo_relay_plugin_registers_shutdown_after_dynamic_retry(tmp_path, monkeypatch): + fake = _FakeNemoRelay() + activation_attempts = 0 + + async def _flaky_activate(config, dynamic_plugins): + nonlocal activation_attempts + activation_attempts += 1 + fake.events.append( + ("plugin.activate_dynamic.attempt", activation_attempts, config, dynamic_plugins) + ) + if activation_attempts == 1: + raise RuntimeError("temporary activation failure") + return _FakePluginActivation(fake.events) + + fake.plugin.activate_dynamic_plugins = _flaky_activate + plugin = _fresh_plugin(monkeypatch, fake) + _enable_dynamic_plugin(tmp_path, monkeypatch) + + plugin.on_session_start(session_id="s1") + plugin.on_session_finalize(session_id="s1") + plugin.on_session_start(session_id="s2") + + runtime = plugin._get_runtime() + assert runtime is not None + assert runtime._plugin_activation is not None + assert runtime._shutdown_registered is True + assert activation_attempts == 2 + runtime.shutdown() + assert any(event[0] == "plugin.activation.close" for event in fake.events) + + +def test_nemo_relay_plugin_attempts_activation_close_after_subscriber_flush_failure( + tmp_path, monkeypatch, caplog +): + fake = _FakeNemoRelay() + + def _failing_flush(): + fake.events.append(("subscribers.flush.failed",)) + raise RuntimeError("flush boom") + + fake.subscribers.flush = _failing_flush + plugin = _fresh_plugin(monkeypatch, fake) + _enable_dynamic_plugin(tmp_path, monkeypatch) + plugin.on_session_start(session_id="s1") + runtime = plugin._get_runtime() + assert runtime is not None + + with caplog.at_level("WARNING"): + runtime.shutdown() + + event_names = [event[0] for event in fake.events] + assert event_names.count("subscribers.flush.failed") == 2 + flush_indices = [ + index for index, name in enumerate(event_names) if name == "subscribers.flush.failed" + ] + assert max(flush_indices) < event_names.index("plugin.activation.close") + assert runtime._plugin_activation is None + assert "subscriber flush failed: flush boom" in caplog.text + + +def test_nemo_relay_plugin_continues_shutdown_after_atif_export_failure( + tmp_path, monkeypatch, caplog +): + fake = _FakeNemoRelay() + + class _FailingAtifExporter(_FakeAtifExporter): + def export_json(self): + self.events.append(("atif.export.failed", self.session_id)) + raise OSError("disk full") + + fake.AtifExporter = lambda session_id, agent_name, agent_version, **kwargs: ( + _FailingAtifExporter(fake.events, session_id, agent_name, agent_version, kwargs) + ) + plugin = _fresh_plugin(monkeypatch, fake) + _enable_dynamic_plugin(tmp_path, monkeypatch) + monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") + monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif")) + plugin.on_session_start(session_id="s1") + runtime = plugin._get_runtime() + assert runtime is not None + + with caplog.at_level("WARNING"): + runtime.shutdown() + + event_names = [event[0] for event in fake.events] + assert event_names.index("atif.export.failed") < event_names.index("atif.deregister") + assert event_names.index("atif.deregister") < event_names.index("plugin.activation.close") + assert runtime._plugin_activation is None + assert "ATIF export failed: disk full" in caplog.text + + def test_nemo_relay_plugin_keeps_plugins_toml_active_while_other_sessions_remain(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) @@ -764,15 +1232,16 @@ mode = "observe_only" ), finish_reason="tool_calls", ) + raw_response = SimpleNamespace( + id="resp-1", + model="demo-model", + choices=[raw_choice], + usage=SimpleNamespace(prompt_tokens=3, completion_tokens=5, total_tokens=8), + ) def next_call(request): seen_request.update(request) - return SimpleNamespace( - id="resp-1", - model="demo-model", - choices=[raw_choice], - usage=SimpleNamespace(prompt_tokens=3, completion_tokens=5, total_tokens=8), - ) + return raw_response response = plugin.on_llm_execution_middleware( session_id="s1", @@ -786,6 +1255,7 @@ mode = "observe_only" next_call=next_call, ) + assert response is raw_response assert response.model == "demo-model" assert response.choices == [raw_choice] assert seen_request["intercepted"] is True diff --git a/tests/plugins/video_gen/test_deepinfra_provider.py b/tests/plugins/video_gen/test_deepinfra_provider.py new file mode 100644 index 000000000000..0c0e82936bc2 --- /dev/null +++ b/tests/plugins/video_gen/test_deepinfra_provider.py @@ -0,0 +1,218 @@ +"""Tests for the bundled DeepInfra video_gen plugin. + +Invariants only — no snapshots of specific model ids. The plugin is a thin +subclass of ``agent.video_gen_provider.OpenAICompatibleVideoGenProvider``; +these tests pin the plugin-specific bits (tag filtering, identity) and the +shared base behaviour exercised through it (OpenAI ``videos`` call shape, +t2v vs i2v routing, download → save). +""" + +from __future__ import annotations + +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +import plugins.video_gen.deepinfra as deepinfra_plugin + + +@pytest.fixture(autouse=True) +def _isolation(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + import hermes_cli.models as _models_mod + monkeypatch.setattr(_models_mod, "_deepinfra_catalog_cache", {}) + monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key") + yield + + +def test_identity_and_availability(monkeypatch): + p = deepinfra_plugin.DeepInfraVideoGenProvider() + assert p.name == "deepinfra" + assert p.display_name == "DeepInfra" + assert p._base_url() == "https://api.deepinfra.com/v1/openai" + assert p.is_available() is True + monkeypatch.delenv("DEEPINFRA_API_KEY", raising=False) + assert p.is_available() is False + + +def test_list_models_filters_by_video_gen_tag(monkeypatch): + """list_models() returns only ``video-gen``-tagged catalog entries.""" + import hermes_cli.models as _models_mod + + def _fake_by_tag(tag, **kw): + assert tag == "video-gen" + return [ + {"id": "vendor/p-video", "metadata": {"description": "fast t2v"}}, + {"id": "vendor/wan-t2v", "metadata": {}}, + ] + + monkeypatch.setattr(_models_mod, "_fetch_deepinfra_models_by_tag", _fake_by_tag) + rows = deepinfra_plugin.DeepInfraVideoGenProvider().list_models() + ids = {row["id"] for row in rows} + assert ids == {"vendor/p-video", "vendor/wan-t2v"} + assert all("display" in r for r in rows) + + +def _fake_openai_with_capture(captured: dict, *, status="succeeded", + data=None, download=b"\x00\x00mp4bytes"): + """Build a fake ``openai`` module whose videos resource records the call. + + Defaults mirror the real DeepInfra job shape: status ``"succeeded"`` and a + ``data`` list carrying the delivery URL. + """ + if data is None: + data = [{"url": "https://cdn.example/out.mp4"}] + + class _FakeVideos: + def create(self, **kwargs): + captured["kwargs"] = kwargs + # Return a terminal status immediately so the bounded poll in + # OpenAICompatibleVideoGenProvider._create_and_poll exits without + # calling retrieve() or sleeping. + return SimpleNamespace(status=status, id="vid_123", error=None, data=data) + + def retrieve(self, video_id): + return SimpleNamespace(status=status, id=video_id, error=None, data=data) + + def download_content(self, video_id): + captured["downloaded_id"] = video_id + return SimpleNamespace(read=lambda: download) + + class _FakeClient: + def __init__(self, api_key=None, base_url=None): + captured["api_key"] = api_key + captured["base_url"] = base_url + self.videos = _FakeVideos() + + fake = MagicMock() + fake.OpenAI = _FakeClient + return fake + + +@contextmanager +def _mock_url_download(captured: dict, raise_exc: Exception | None = None): + """Patch the shared ``save_url_video`` helper the base provider calls.""" + import agent.video_gen_provider as base + from pathlib import Path + + def _fake_save_url_video(url, *, prefix="video", **kw): + captured["url"] = url + if raise_exc: + raise raise_exc + return Path(f"/home/x/.hermes/cache/videos/{prefix}_test.mp4") + + with patch.object(base, "save_url_video", _fake_save_url_video): + yield + + +def test_generate_text_to_video_downloads_url_and_saves_locally(): + """t2v happy path: SDK called with DeepInfra base_url + key; status + 'succeeded' + data[].url → bytes downloaded and saved to a local file.""" + captured: dict = {} + with patch.dict("sys.modules", {"openai": _fake_openai_with_capture(captured)}), \ + _mock_url_download(captured): + result = deepinfra_plugin.DeepInfraVideoGenProvider().generate( + prompt="a red cube rotating", model="vendor/test-vid", duration=5, + ) + assert result["success"] is True + assert result["modality"] == "text" + assert result["video"].endswith(".mp4") and "cache/videos" in result["video"] + assert captured["url"] == "https://cdn.example/out.mp4" + assert "deepinfra" in captured["base_url"] + assert captured["api_key"] == "test-key" + assert captured["kwargs"]["model"] == "vendor/test-vid" + assert captured["kwargs"]["seconds"] == "5" + # No image_url ⇒ no image-to-video field passed through. + assert "image_url" not in captured["kwargs"].get("extra_body", {}) + + +def test_generate_returns_url_when_local_save_fails(): + """If downloading the delivery URL fails, fall back to returning the URL.""" + captured: dict = {} + with patch.dict("sys.modules", {"openai": _fake_openai_with_capture(captured)}), \ + _mock_url_download(captured, raise_exc=OSError("network down")): + result = deepinfra_plugin.DeepInfraVideoGenProvider().generate( + prompt="x", model="vendor/test-vid", + ) + assert result["success"] is True + assert result["video"] == "https://cdn.example/out.mp4" + + +def test_generate_falls_back_to_download_when_no_url(): + """OpenAI/Sora style: no data[].url → download_content bytes saved locally.""" + captured: dict = {} + fake = _fake_openai_with_capture(captured, status="completed", data=[]) + with patch.dict("sys.modules", {"openai": fake}): + result = deepinfra_plugin.DeepInfraVideoGenProvider().generate( + prompt="x", model="vendor/test-vid", + ) + assert result["success"] is True + assert captured["downloaded_id"] == "vid_123" + assert result["video"].endswith(".mp4") + + +def test_generate_image_to_video_routes_via_extra_body(): + """Presence of image_url routes to i2v and rides in extra_body.""" + captured: dict = {} + with patch.dict("sys.modules", {"openai": _fake_openai_with_capture(captured)}), \ + _mock_url_download(captured): + result = deepinfra_plugin.DeepInfraVideoGenProvider().generate( + prompt="animate this", model="vendor/test-vid", + image_url="https://example.com/cat.jpg", negative_prompt="blurry", + ) + assert result["success"] is True + assert result["modality"] == "image" + extra = captured["kwargs"]["extra_body"] + assert extra["image_url"] == "https://example.com/cat.jpg" + assert extra["negative_prompt"] == "blurry" + + +def test_generate_errors_when_key_missing(monkeypatch): + monkeypatch.delenv("DEEPINFRA_API_KEY", raising=False) + result = deepinfra_plugin.DeepInfraVideoGenProvider().generate( + prompt="x", model="vendor/test-vid", + ) + assert result["success"] is False + assert result["error_type"] == "missing_credentials" + + +def test_generate_errors_when_job_not_completed(): + """A non-completed job status surfaces a JSON-serializable job_failed error. + + ``video.error`` is a structured SDK object (pydantic ``VideoCreateError``), + not a string — the provider must str() it so the response dict survives the + tool layer's ``json.dumps``. We simulate that with a non-serializable object. + """ + import json + + captured: dict = {} + fake = _fake_openai_with_capture(captured) + + class _NonSerializableError: + def __str__(self): + return "content policy violation" + + class _FailingVideos: + def create(self, **kwargs): + return SimpleNamespace( + status="failed", id="vid_x", error=_NonSerializableError(), data=None + ) + + def retrieve(self, video_id): # pragma: no cover - status already terminal + return SimpleNamespace(status="failed", id=video_id, error=None, data=None) + + def _client(api_key=None, base_url=None): + return SimpleNamespace(videos=_FailingVideos()) + + fake.OpenAI = _client + with patch.dict("sys.modules", {"openai": fake}): + result = deepinfra_plugin.DeepInfraVideoGenProvider().generate( + prompt="x", model="vendor/test-vid", + ) + assert result["success"] is False + assert result["error_type"] == "job_failed" + assert "content policy violation" in result["error"] + # Must not raise — this is the regression the str() guard prevents. + json.dumps(result) diff --git a/tests/providers/test_fetch_models_base_url.py b/tests/providers/test_fetch_models_base_url.py index 5db1f61d1cdd..7ccbed044f28 100644 --- a/tests/providers/test_fetch_models_base_url.py +++ b/tests/providers/test_fetch_models_base_url.py @@ -117,6 +117,81 @@ class TestCustomProviderBaseUrlPassthrough: server.shutdown() +class _RedirectingHandler(BaseHTTPRequestHandler): + """Redirects /models to a configurable target and records received headers.""" + + redirect_to = "" # full URL to redirect /models to (set per test) + received_headers: dict = {} + + def do_GET(self): + if self.path.rstrip("/") == "/models": + self.send_response(302) + self.send_header("Location", type(self).redirect_to) + self.end_headers() + else: + _RedirectingHandler.received_headers = dict(self.headers) + body = json.dumps({"data": [{"id": "redirected-model"}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass + + +class TestFetchModelsRedirectCredentialStripping: + """Credential headers must not follow a redirect outside the original origin.""" + + def _run(self, redirect_to): + """redirect_to is a callable (first_port, second_port) -> Location URL.""" + _RedirectingHandler.received_headers = {} + server = HTTPServer(("127.0.0.1", 0), _RedirectingHandler) + second_server = HTTPServer(("127.0.0.1", 0), _RedirectingHandler) + port = server.server_address[1] + second_port = second_server.server_address[1] + _RedirectingHandler.redirect_to = redirect_to(port, second_port) + Thread(target=server.serve_forever, daemon=True).start() + Thread(target=second_server.serve_forever, daemon=True).start() + try: + profile = ProviderProfile( + name="test", + base_url=f"http://127.0.0.1:{port}", + default_headers={"x-api-key": "default-header-secret"}, + ) + result = profile.fetch_models(api_key="bearer-secret") + finally: + server.shutdown() + second_server.shutdown() + headers = {k.lower(): v for k, v in _RedirectingHandler.received_headers.items()} + return result, headers + + def test_cross_host_redirect_strips_credentials(self): + result, headers = self._run( + lambda port, _: f"http://localhost:{port}/redirected" + ) + assert result == ["redirected-model"] # fetch itself still works + assert "authorization" not in headers + assert "x-api-key" not in headers + + def test_same_host_different_port_redirect_strips_credentials(self): + """A different port is a different origin — it can be a different service.""" + result, headers = self._run( + lambda _, second_port: f"http://127.0.0.1:{second_port}/redirected" + ) + assert result == ["redirected-model"] + assert "authorization" not in headers + assert "x-api-key" not in headers + + def test_same_origin_redirect_keeps_credentials(self): + result, headers = self._run( + lambda port, _: f"http://127.0.0.1:{port}/redirected" + ) + assert result == ["redirected-model"] + assert headers.get("authorization") == "Bearer bearer-secret" + assert headers.get("x-api-key") == "default-header-secret" + + class TestModelPickerBaseUrlIntegration: """The /model picker path should pass model.base_url to fetch_models.""" diff --git a/tests/providers/test_plugin_discovery.py b/tests/providers/test_plugin_discovery.py index fba5a02df11c..ee62cccd5e42 100644 --- a/tests/providers/test_plugin_discovery.py +++ b/tests/providers/test_plugin_discovery.py @@ -68,7 +68,7 @@ def test_all_profiles_register(): # Spot-check representative providers from different categories for required in ( "openrouter", "anthropic", "custom", "bedrock", "openai-codex", - "minimax-oauth", "gmi", "xiaomi", "alibaba-coding-plan", + "minimax-oauth", "gmi", "xiaomi", "alibaba-coding-plan", "fireworks", ): assert required in names, f"Missing profile: {required}" diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index 3eb234030483..bd450cb56009 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -314,12 +314,12 @@ class TestOpenRouterProfile: Covers the full real config range produced by ``hermes_constants.parse_reasoning_effort`` — - ``VALID_REASONING_EFFORTS = (minimal, low, medium, high, xhigh)``. + ``VALID_REASONING_EFFORTS`` (including max and ultra). """ p = get_provider_profile("openrouter") model = "anthropic/claude-fable-5" assert self._is_mandatory(model) # fixture really is mandatory - for effort in ("minimal", "low", "medium", "high", "xhigh"): + for effort in ("minimal", "low", "medium", "high", "xhigh", "max", "ultra"): eb, tl = p.build_api_kwargs_extras( reasoning_config={"enabled": True, "effort": effort}, supports_reasoning=True, @@ -342,14 +342,12 @@ class TestOpenRouterProfile: def test_mandatory_anthropic_verbosity_is_value_agnostic_passthrough(self): """The mapping passes the effort value through verbatim — it must NOT - clamp or whitelist. ``xhigh`` is a real config value; ``max`` is not - producible by ``parse_reasoning_effort`` today but OpenRouter accepts it - for Claude (live-proven in #43432), so a forward value must survive + clamp or whitelist. Extended values must survive rather than be silently dropped. The OpenAI SDK type only literals ``low|medium|high`` but it's a TypedDict (no runtime validation), so the extended scale reaches the wire untouched.""" p = get_provider_profile("openrouter") - for effort in ("xhigh", "max"): + for effort in ("xhigh", "max", "ultra"): _, tl = p.build_api_kwargs_extras( reasoning_config={"enabled": True, "effort": effort}, supports_reasoning=True, @@ -414,6 +412,19 @@ class TestNousProfile: body = p.build_extra_body() assert body["tags"] == nous_portal_tags() + def test_extra_body_with_provider_preferences(self): + from agent.portal_tags import nous_portal_tags + + p = get_provider_profile("nous") + assert p is not None + preferences = {"only": ["deepseek"], "ignore": ["deepinfra"]} + body = p.build_extra_body(provider_preferences=preferences) + + assert body == { + "tags": nous_portal_tags(), + "provider": preferences, + } + def test_auth_type(self): p = get_provider_profile("nous") assert p.auth_type == "oauth_device_code" @@ -464,6 +475,69 @@ class TestQwenProfile: assert isinstance(result[1]["content"], list) assert result[1]["content"][0]["text"] == "hello" + def test_prepare_messages_copy_on_write(self): + p = get_provider_profile("qwen-oauth") + system_part = {"type": "text", "text": "Be helpful"} + msgs = [ + {"role": "system", "content": [system_part]}, + {"role": "assistant", "content": [{"type": "text", "text": "unchanged"}]}, + {"role": "user", "content": ["hello"]}, + ] + + result = p.prepare_messages(msgs) + + assert result is not msgs + assert result[0] is not msgs[0] + assert result[0]["content"] is not msgs[0]["content"] + assert result[0]["content"][0] is not system_part + assert result[0]["content"][0]["cache_control"] == {"type": "ephemeral"} + assert "cache_control" not in system_part + assert result[1] is msgs[1] + assert result[2] is not msgs[2] + assert result[2]["content"] == [{"type": "text", "text": "hello"}] + assert msgs[2]["content"] == ["hello"] + + def test_prepare_messages_does_not_poison_strict_provider_history(self): + qwen = get_provider_profile("qwen-oauth") + msgs = [ + {"role": "system", "content": [{"type": "text", "text": "Be helpful"}]}, + {"role": "user", "content": "hello"}, + ] + + qwen_result = qwen.prepare_messages(msgs) + + assert qwen_result[0]["content"][0]["cache_control"] == {"type": "ephemeral"} + assert "cache_control" not in msgs[0]["content"][0] + assert msgs[1]["content"] == "hello" + + def test_prepare_messages_protects_nested_image_url_retry_mutation(self): + qwen = get_provider_profile("qwen-oauth") + image_url = {"url": "data:image/png;base64,original"} + msgs = [ + {"role": "system", "content": "Be helpful"}, + { + "role": "user", + "content": [ + {"type": "text", "text": "see image"}, + {"type": "image_url", "image_url": image_url}, + ], + }, + ] + + qwen_result = qwen.prepare_messages(msgs) + + assert qwen_result[1] is not msgs[1] + assert qwen_result[1]["content"] is not msgs[1]["content"] + assert qwen_result[1]["content"][1] is not msgs[1]["content"][1] + assert qwen_result[1]["content"][1]["image_url"] is not image_url + + qwen_result[1]["content"][1]["image_url"]["url"] = ( + "data:image/png;base64,shrunk" + ) + assert msgs[1]["content"][1]["image_url"]["url"] == ( + "data:image/png;base64,original" + ) + def test_metadata_top_level(self): p = get_provider_profile("qwen-oauth") meta = {"sessionId": "s123", "promptId": "p456"} diff --git a/tests/providers/test_transport_parity.py b/tests/providers/test_transport_parity.py index dec63edf8eb4..b77526917c94 100644 --- a/tests/providers/test_transport_parity.py +++ b/tests/providers/test_transport_parity.py @@ -208,6 +208,21 @@ class TestNousParity: ) assert kw["extra_body"]["tags"] == nous_portal_tags() + def test_provider_preferences(self, transport): + preferences = { + "only": ["deepseek"], + "ignore": ["deepinfra"], + "sort": "throughput", + } + kw = transport.build_kwargs( + model="deepseek/deepseek-v4-flash", + messages=_simple_messages(), + tools=None, + provider_profile=get_provider_profile("nous"), + provider_preferences=preferences, + ) + assert kw["extra_body"]["provider"] == preferences + def test_reasoning_omitted_when_disabled(self, transport): """Nous special case: reasoning omitted entirely when disabled.""" kw = transport.build_kwargs( diff --git a/tests/run_agent/test_63425_credential_pool_auto_detect.py b/tests/run_agent/test_63425_credential_pool_auto_detect.py new file mode 100644 index 000000000000..82be64845152 --- /dev/null +++ b/tests/run_agent/test_63425_credential_pool_auto_detect.py @@ -0,0 +1,178 @@ +"""Reproduction test for issue #63425: Provider auto-detection discards credential pools. + +When AIAgent is constructed with provider=None and a recognized endpoint URL, +the provider auto-detection works but the credential pool is discarded because +pool validation runs before URL-based provider inference. +""" +from types import SimpleNamespace +from unittest.mock import patch, MagicMock + +import pytest + + +def _mock_client(api_key="test-key", base_url="https://api.anthropic.com"): + c = MagicMock() + c.api_key = api_key + c.base_url = base_url + c._default_headers = None + return c + + +class TestCredentialPoolPreservedOnAutoDetect: + """Issue #63425: credential pool should survive provider auto-detection.""" + + def test_anthropic_pool_preserved_with_url_auto_detect(self): + """When provider=None and base_url=api.anthropic.com, the passed + credential_pool should remain attached after auto-detection.""" + from agent.agent_init import init_agent + + # Build a minimal agent-like object (like tests use object.__new__) + from run_agent import AIAgent + agent = object.__new__(AIAgent) + agent._base_url = "" + agent._base_url_lower = "" + agent._base_url_hostname = "" + + pool = SimpleNamespace(provider="anthropic") + + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(None, None)), \ + patch("run_agent.get_tool_definitions", return_value=[]), \ + patch('agent.anthropic_adapter.build_anthropic_client', return_value=MagicMock()), \ + patch('agent.anthropic_adapter.resolve_anthropic_token', return_value=''), \ + patch('agent.anthropic_adapter._is_oauth_token', return_value=False), \ + patch('agent.azure_identity_adapter.is_token_provider', return_value=False), \ + patch('hermes_cli.model_normalize.normalize_model_for_provider', return_value='test-model'), \ + patch('agent.credential_pool.load_pool', return_value=MagicMock()), \ + patch('hermes_cli.config.load_config', return_value={}), \ + patch('hermes_cli.config.get_compatible_custom_providers', return_value=[]), \ + patch('agent.iteration_budget.IterationBudget'), \ + patch('hermes_cli.config.cfg_get', return_value=None): + + init_agent( + agent, + base_url="https://api.anthropic.com", + api_key="test-key", + provider=None, + model="test-model", + credential_pool=pool, + skip_context_files=True, + skip_memory=True, + quiet_mode=True, + ) + + print(f"agent.provider = {agent.provider!r}") + print(f"agent.api_mode = {agent.api_mode!r}") + print(f"agent._credential_pool is pool = {agent._credential_pool is pool}") + + assert agent.provider == "anthropic", ( + f"Provider should be auto-detected as 'anthropic', got {agent.provider!r}" + ) + assert agent.api_mode == "anthropic_messages", ( + f"api_mode should be 'anthropic_messages', got {agent.api_mode!r}" + ) + assert agent._credential_pool is pool, ( + "Credential pool was discarded! agent._credential_pool is NOT the " + "same object that was passed to AIAgent().\n" + f" Expected: {id(pool)}\n" + f" Got: {id(agent._credential_pool)}" + ) + + def test_codex_pool_preserved_with_url_auto_detect(self): + """When provider=None and base_url=chatgpt.com/backend-api/codex, the + passed credential_pool should remain attached.""" + from agent.agent_init import init_agent + from run_agent import AIAgent + agent = object.__new__(AIAgent) + agent._base_url = "" + agent._base_url_lower = "" + agent._base_url_hostname = "" + + pool = SimpleNamespace(provider="openai-codex") + + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(_mock_client("key", "https://chatgpt.com/backend-api/codex"), None)), \ + patch("run_agent.get_tool_definitions", return_value=[]), \ + patch("run_agent.OpenAI", return_value=MagicMock()), \ + patch('agent.anthropic_adapter.build_anthropic_client', return_value=MagicMock()), \ + patch('agent.anthropic_adapter.resolve_anthropic_token', return_value=''), \ + patch('agent.anthropic_adapter._is_oauth_token', return_value=False), \ + patch('agent.azure_identity_adapter.is_token_provider', return_value=False), \ + patch('hermes_cli.model_normalize.normalize_model_for_provider', return_value='test-model'), \ + patch('agent.credential_pool.load_pool', return_value=MagicMock()), \ + patch('hermes_cli.config.load_config', return_value={}), \ + patch('hermes_cli.config.get_compatible_custom_providers', return_value=[]), \ + patch('agent.iteration_budget.IterationBudget'), \ + patch('hermes_cli.config.cfg_get', return_value=None): + + init_agent( + agent, + base_url="https://chatgpt.com/backend-api/codex", + api_key="test-key", + provider=None, + model="gpt-5.5", + credential_pool=pool, + skip_context_files=True, + skip_memory=True, + quiet_mode=True, + ) + + print(f"\nagent.provider = {agent.provider!r}") + print(f"agent.api_mode = {agent.api_mode!r}") + print(f"agent._credential_pool is pool = {agent._credential_pool is pool}") + + assert agent.provider == "openai-codex", ( + f"Provider should be auto-detected as 'openai-codex', got {agent.provider!r}" + ) + assert agent._credential_pool is pool, ( + "Credential pool was discarded! agent._credential_pool is NOT the " + f"same object. Expected: {id(pool)}, Got: {id(agent._credential_pool)}" + ) + + def test_xai_pool_preserved_with_url_auto_detect(self): + """When provider=None and base_url=api.x.ai, the passed + credential_pool should remain attached.""" + from agent.agent_init import init_agent + from run_agent import AIAgent + agent = object.__new__(AIAgent) + agent._base_url = "" + agent._base_url_lower = "" + agent._base_url_hostname = "" + + pool = SimpleNamespace(provider="xai") + + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(_mock_client("key", "https://api.x.ai"), None)), \ + patch("run_agent.get_tool_definitions", return_value=[]), \ + patch("run_agent.OpenAI", return_value=MagicMock()), \ + patch('agent.anthropic_adapter.build_anthropic_client', return_value=MagicMock()), \ + patch('agent.anthropic_adapter.resolve_anthropic_token', return_value=''), \ + patch('agent.anthropic_adapter._is_oauth_token', return_value=False), \ + patch('agent.azure_identity_adapter.is_token_provider', return_value=False), \ + patch('hermes_cli.model_normalize.normalize_model_for_provider', return_value='test-model'), \ + patch('agent.credential_pool.load_pool', return_value=MagicMock()), \ + patch('hermes_cli.config.load_config', return_value={}), \ + patch('hermes_cli.config.get_compatible_custom_providers', return_value=[]), \ + patch('agent.iteration_budget.IterationBudget'), \ + patch('hermes_cli.config.cfg_get', return_value=None): + + init_agent( + agent, + base_url="https://api.x.ai", + api_key="test-key", + provider=None, + model="grok-5", + credential_pool=pool, + skip_context_files=True, + skip_memory=True, + quiet_mode=True, + ) + + print(f"\nagent.provider = {agent.provider!r}") + print(f"agent.api_mode = {agent.api_mode!r}") + print(f"agent._credential_pool is pool = {agent._credential_pool is pool}") + + assert agent.provider == "xai", ( + f"Provider should be auto-detected as 'xai', got {agent.provider!r}" + ) + assert agent._credential_pool is pool, ( + "Credential pool was discarded! agent._credential_pool is NOT the " + f"same object. Expected: {id(pool)}, Got: {id(agent._credential_pool)}" + ) diff --git a/tests/run_agent/test_agent_guardrails.py b/tests/run_agent/test_agent_guardrails.py index eb89cdda9c00..bcec4e3d7c8f 100644 --- a/tests/run_agent/test_agent_guardrails.py +++ b/tests/run_agent/test_agent_guardrails.py @@ -8,10 +8,26 @@ Covers three static methods on AIAgent (inspired by PR #1321 — @alireza78a): import types -from run_agent import AIAgent -from tools.delegate_tool import _get_max_concurrent_children +import pytest -MAX_CONCURRENT_CHILDREN = _get_max_concurrent_children() +from run_agent import AIAgent + +# Pin the concurrency limit instead of reading the runtime config. +# _cap_delegate_task_calls() resolves _get_max_concurrent_children() at CALL +# time (inside a per-test hermetic HERMES_HOME), but this module previously +# froze the value at IMPORT time — before the hermetic fixture ran — so a +# developer machine with delegation.max_concurrent_children in the real +# ~/.hermes/config.yaml saw a different limit at import vs call and the +# truncation tests failed locally while passing on CI. +MAX_CONCURRENT_CHILDREN = 3 + + +@pytest.fixture(autouse=True) +def _pin_max_concurrent_children(monkeypatch): + monkeypatch.setattr( + "tools.delegate_tool._get_max_concurrent_children", + lambda: MAX_CONCURRENT_CHILDREN, + ) # --------------------------------------------------------------------------- diff --git a/tests/run_agent/test_anthropic_prompt_cache_policy.py b/tests/run_agent/test_anthropic_prompt_cache_policy.py index ba6e54f03720..679a9219fbe9 100644 --- a/tests/run_agent/test_anthropic_prompt_cache_policy.py +++ b/tests/run_agent/test_anthropic_prompt_cache_policy.py @@ -75,6 +75,57 @@ class TestOpenRouter: assert agent._anthropic_prompt_cache_policy() == (False, False) +class TestKimiMoonshotOnOpenRouter: + """Kimi/Moonshot on OpenRouter honour envelope-layout cache_control (#25970).""" + + def test_kimi_k26_on_openrouter_caches_with_envelope_layout(self): + agent = _make_agent( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="moonshotai/kimi-k2.6", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + def test_moonshot_v1_on_openrouter_caches_with_envelope_layout(self): + agent = _make_agent( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="moonshotai/moonshot-v1-8k", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + def test_kimi_on_nous_portal_caches_with_envelope_layout(self): + agent = _make_agent( + provider="nous", + base_url="https://api.nousresearch.com/v1", + api_mode="chat_completions", + model="moonshotai/kimi-k2.6", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + def test_kimi_bare_release_slug_on_openrouter_caches(self): + """Bare release slugs (k2-thinking) lack the 'kimi'/'moonshot' substring; + the canonical family matcher must still catch them.""" + agent = _make_agent( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="k2-thinking", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + def test_kimi_on_non_openrouter_host_does_not_cache(self): + agent = _make_agent( + provider="custom", + base_url="https://api.moonshot.cn/v1", + api_mode="chat_completions", + model="moonshotai/kimi-k2.6", + ) + assert agent._anthropic_prompt_cache_policy() == (False, False) + + class TestThirdPartyAnthropicGateway: """Third-party gateways speaking the Anthropic protocol (MiniMax, Zhipu GLM, LiteLLM).""" diff --git a/tests/run_agent/test_background_review_cache_parity.py b/tests/run_agent/test_background_review_cache_parity.py index 58a2dfa4812f..b7e27245d265 100644 --- a/tests/run_agent/test_background_review_cache_parity.py +++ b/tests/run_agent/test_background_review_cache_parity.py @@ -41,6 +41,9 @@ def _make_agent_stub(agent_cls): # Non-None so the test catches a missing-kwarg regression. agent.enabled_toolsets = ["memory", "skills", "terminal"] agent.disabled_toolsets = ["spotify", "feishu_doc"] + # Non-None so the test catches reasoning_config NOT being inherited — + # which would put the fork into a different Anthropic cache namespace. + agent.reasoning_config = {"enabled": True, "effort": "medium"} return agent @@ -55,28 +58,52 @@ class _SyncThread: self._target() -class _ReviewAgentRecorder: - """Stand-in for the review-fork AIAgent that records the prompt assignment.""" +def _make_recorder_class(captured=None, record_on_run=()): + """Build a Recorder class standing in for the review-fork AIAgent. - def __init__(self, *args, **kwargs): - self._cached_system_prompt = None - self._memory_write_origin = None - self._memory_write_context = None - self._memory_store = None - self._memory_enabled = None - self._user_profile_enabled = None - self._memory_nudge_interval = None - self._skill_nudge_interval = None - self.suppress_status_output = None + Keeps the stub attribute list in ONE place: when + ``_spawn_background_review`` starts touching a new fork attribute, only + this factory needs the extra stub — not one copy per test. - def run_conversation(self, *args, **kwargs): - raise RuntimeError("stop after recording state — don't actually call the API") + ``captured`` (dict): if given, ``__init__`` stores the full constructor + kwargs under ``captured["init_kwargs"]`` so tests can assert on both + kwarg values and kwarg *presence*. + ``record_on_run``: instance attribute names copied into ``captured`` when + ``run_conversation`` fires — for values the production code assigns + after construction. + """ - def shutdown_memory_provider(self): - pass + class _Recorder: + def __init__(self, *args, **kwargs): + if captured is not None: + captured["init_kwargs"] = dict(kwargs) + self._cached_system_prompt = None + self._memory_write_origin = None + self._memory_write_context = None + self._memory_store = None + self._memory_enabled = None + self._user_profile_enabled = None + self._memory_nudge_interval = None + self._skill_nudge_interval = None + self.suppress_status_output = None + self.session_start = None + self.session_id = None - def close(self): - pass + def run_conversation(self, *args, **kwargs): + if captured is not None: + for _name in record_on_run: + captured[_name] = getattr(self, _name) + raise RuntimeError( + "stop after recording — don't actually call the API" + ) + + def shutdown_memory_provider(self): + pass + + def close(self): + pass + + return _Recorder def test_review_fork_inherits_parent_cached_system_prompt(): @@ -94,27 +121,21 @@ def test_review_fork_inherits_parent_cached_system_prompt(): captured = {} parent_prompt = agent._cached_system_prompt - # Hook the assignment site: record what gets put on the review agent. - real_recorder_init = _ReviewAgentRecorder.__init__ + _Recorder = _make_recorder_class() - def _recorder_init(self, *args, **kwargs): - real_recorder_init(self, *args, **kwargs) - # The actual production code assigns _cached_system_prompt AFTER __init__, - # so we need to capture it on attribute set. Use a property-style sentinel - # via __setattr__ on this instance. - - with patch.object(run_agent, "AIAgent", _ReviewAgentRecorder), \ + with patch.object(run_agent, "AIAgent", _Recorder), \ patch("threading.Thread", _SyncThread): - # Wrap the recorder's __setattr__ so we can see the _cached_system_prompt - # write that _spawn_background_review performs after construction. - orig_setattr = _ReviewAgentRecorder.__setattr__ + # The production code assigns _cached_system_prompt AFTER __init__, + # so wrap the recorder's __setattr__ to see that post-construction + # write from _spawn_background_review. + orig_setattr = _Recorder.__setattr__ def _spy_setattr(self, name, value): if name == "_cached_system_prompt": captured["written_prompt"] = value orig_setattr(self, name, value) - with patch.object(_ReviewAgentRecorder, "__setattr__", _spy_setattr): + with patch.object(_Recorder, "__setattr__", _spy_setattr): agent._spawn_background_review( messages_snapshot=[], review_memory=True, @@ -144,31 +165,9 @@ def test_review_fork_pins_session_start_and_session_id(): agent = _make_agent_stub(run_agent.AIAgent) captured = {} - - class _Recorder: - def __init__(self, *args, **kwargs): - self._cached_system_prompt = None - self._memory_write_origin = None - self._memory_write_context = None - self._memory_store = None - self._memory_enabled = None - self._user_profile_enabled = None - self._memory_nudge_interval = None - self._skill_nudge_interval = None - self.suppress_status_output = None - self.session_start = None - self.session_id = None - - def run_conversation(self, *args, **kwargs): - captured["session_start"] = self.session_start - captured["session_id"] = self.session_id - raise RuntimeError("stop after recording") - - def shutdown_memory_provider(self): - pass - - def close(self): - pass + _Recorder = _make_recorder_class( + captured, record_on_run=("session_start", "session_id") + ) with patch.object(run_agent, "AIAgent", _Recorder), \ patch("threading.Thread", _SyncThread): @@ -195,31 +194,7 @@ def test_review_fork_inherits_parent_toolset_config(): agent = _make_agent_stub(run_agent.AIAgent) captured = {} - - class _Recorder: - def __init__(self, *args, **kwargs): - captured["enabled_toolsets"] = kwargs.get("enabled_toolsets") - captured["disabled_toolsets"] = kwargs.get("disabled_toolsets") - self._cached_system_prompt = None - self._memory_write_origin = None - self._memory_write_context = None - self._memory_store = None - self._memory_enabled = None - self._user_profile_enabled = None - self._memory_nudge_interval = None - self._skill_nudge_interval = None - self.suppress_status_output = None - self.session_start = None - self.session_id = None - - def run_conversation(self, *args, **kwargs): - raise RuntimeError("stop after recording — don't actually call the API") - - def shutdown_memory_provider(self): - pass - - def close(self): - pass + _Recorder = _make_recorder_class(captured) with patch.object(run_agent, "AIAgent", _Recorder), \ patch("threading.Thread", _SyncThread): @@ -229,11 +204,95 @@ def test_review_fork_inherits_parent_toolset_config(): review_skills=False, ) - assert captured.get("enabled_toolsets") == agent.enabled_toolsets, ( - f"enabled_toolsets mismatch: {captured.get('enabled_toolsets')!r} " + init_kwargs = captured.get("init_kwargs", {}) + assert init_kwargs.get("enabled_toolsets") == agent.enabled_toolsets, ( + f"enabled_toolsets mismatch: {init_kwargs.get('enabled_toolsets')!r} " f"vs expected {agent.enabled_toolsets!r}" ) - assert captured.get("disabled_toolsets") == agent.disabled_toolsets, ( - f"disabled_toolsets mismatch: {captured.get('disabled_toolsets')!r} " + assert init_kwargs.get("disabled_toolsets") == agent.disabled_toolsets, ( + f"disabled_toolsets mismatch: {init_kwargs.get('disabled_toolsets')!r} " f"vs expected {agent.disabled_toolsets!r}" ) + + +def test_review_fork_inherits_parent_reasoning_config(): + """``reasoning_config`` parity on the default (non-routed) path. + + The fork must inherit the parent's value so the request body's + ``thinking`` / ``output_config`` match — Anthropic's cache is + namespaced by ``thinking`` presence. + """ + import run_agent + + agent = _make_agent_stub(run_agent.AIAgent) + + captured = {} + _Recorder = _make_recorder_class(captured) + + with patch.object(run_agent, "AIAgent", _Recorder), \ + patch("threading.Thread", _SyncThread): + agent._spawn_background_review( + messages_snapshot=[], + review_memory=True, + review_skills=False, + ) + + init_kwargs = captured.get("init_kwargs", {}) + assert init_kwargs.get("reasoning_config") == agent.reasoning_config, ( + f"reasoning_config mismatch: {init_kwargs.get('reasoning_config')!r} " + f"vs expected {agent.reasoning_config!r}" + ) + + +def test_routed_review_fork_does_not_inherit_reasoning_config(): + """Routed aux path: the fork must NOT inherit the parent's reasoning_config. + + When ``auxiliary.background_review.{provider,model}`` routes the review + to a different model, cache parity is moot (the cache is cold on that + model regardless) and the parent's effort vocabulary may be invalid for + the routed model/provider (OpenRouter ``extra_body.reasoning.effort`` is + forwarded unclamped; codex_responses passes ``max``/``ultra`` through + unmapped except on gpt-5.6/xAI). The routed fork must fall back to + provider defaults, mirroring the ``not _routed`` gate on + ``_cached_system_prompt`` inheritance. + """ + import run_agent + import agent.background_review as bg_review + + agent_stub = _make_agent_stub(run_agent.AIAgent) + + captured = {} + _Recorder = _make_recorder_class(captured) + + routed_runtime = { + "provider": "openrouter", + "model": "aux-cheap-model", + "api_key": "test-key", + "base_url": None, + "api_mode": None, + "credential_pool": None, + "request_overrides": {}, + "max_tokens": None, + "command": None, + "args": [], + "routed": True, + } + + with patch.object(run_agent, "AIAgent", _Recorder), \ + patch.object(bg_review, "_resolve_review_runtime", + return_value=routed_runtime), \ + patch("threading.Thread", _SyncThread): + agent_stub._spawn_background_review( + messages_snapshot=[], + review_memory=True, + review_skills=False, + ) + + init_kwargs = captured.get("init_kwargs", {}) + assert "reasoning_config" not in init_kwargs, ( + f"Routed review fork was passed the parent's reasoning_config " + f"({init_kwargs.get('reasoning_config')!r}). On the routed path the " + "cache is cold (no parity benefit) and the parent's effort value may " + "be invalid for the routed model/provider — it must be omitted so " + "the fork uses provider defaults." + ) diff --git a/tests/run_agent/test_background_review_cost_controls.py b/tests/run_agent/test_background_review_cost_controls.py index 5ca47b2a0f99..7e5da983faf4 100644 --- a/tests/run_agent/test_background_review_cost_controls.py +++ b/tests/run_agent/test_background_review_cost_controls.py @@ -8,6 +8,7 @@ Covers the two behaviors this change adds: Pure-function / config-driven; no live model calls. """ +from typing import Any from unittest.mock import patch from agent import background_review as br @@ -28,6 +29,9 @@ class _FakeAgent: def __init__(self, provider="openai-codex", model="gpt-5.5"): self.provider = provider self.model = model + self._credential_pool: Any = None + self.request_overrides = {} + self.max_tokens: int | None = None def _current_main_runtime(self): return { @@ -56,6 +60,9 @@ def test_routing_to_different_model_marks_routed_and_resolves_credentials(): fake_rp = { "provider": "openrouter", "api_key": "or-key", "base_url": "https://openrouter.ai/api/v1", "api_mode": "chat_completions", + "credential_pool": "routed-pool", + "request_overrides": {"extra_body": {"store": False}}, + "max_output_tokens": 2048, } with patch("hermes_cli.config.load_config", return_value=cfg), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=fake_rp): @@ -64,6 +71,21 @@ def test_routing_to_different_model_marks_routed_and_resolves_credentials(): assert rt["provider"] == "openrouter" assert rt["model"] == "google/gemini-3-flash-preview" assert rt["api_key"] == "or-key" + assert rt["credential_pool"] == "routed-pool" + assert rt["request_overrides"] == {"extra_body": {"store": False}} + assert rt["max_tokens"] == 2048 + + +def test_unrouted_runtime_keeps_parent_pool_and_overrides(): + agent = _FakeAgent() + agent._credential_pool = "parent-pool" + agent.request_overrides = {"service_tier": "priority"} + agent.max_tokens = 4096 + with patch("hermes_cli.config.load_config", return_value={}): + rt = br._resolve_review_runtime(agent) + assert rt["credential_pool"] == "parent-pool" + assert rt["request_overrides"] == {"service_tier": "priority"} + assert rt["max_tokens"] == 4096 def test_routing_same_model_as_parent_is_not_routed(): diff --git a/tests/run_agent/test_codex_app_server_compaction.py b/tests/run_agent/test_codex_app_server_compaction.py index 4bd5e8431dd2..93bbe0eadf22 100644 --- a/tests/run_agent/test_codex_app_server_compaction.py +++ b/tests/run_agent/test_codex_app_server_compaction.py @@ -102,6 +102,8 @@ def test_codex_app_server_manual_compression_routes_to_codex_thread(): assert agent._codex_session.calls == 1 assert agent.context_compressor.compression_count == 1 assert agent.context_compressor.last_compression_rough_tokens == 100000 + # This minimal fake compressor does not implement update_from_response(), + # so the runtime preserves its existing pending-usage bookkeeping here. assert agent.context_compressor.last_prompt_tokens == -1 assert agent.context_compressor.last_completion_tokens == 0 assert agent.context_compressor.awaiting_real_usage_after_compression is True @@ -192,3 +194,31 @@ def test_codex_app_server_native_compaction_notice_emits_status_and_event(): }, ) ] + + +def test_codex_native_boundary_clears_stale_hermes_fallback_streak(): + from unittest.mock import patch + + from agent.context_compressor import ContextCompressor + + with patch( + "agent.context_compressor.get_model_context_length", + return_value=100_000, + ): + compressor = ContextCompressor(model="test-model", quiet_mode=True) + compressor._fallback_compression_streak = 1 + compressor._last_summary_fallback_used = True + + agent = DummyAgent( + TurnResult(thread_id="thread-1", turn_id="normal-turn-1") + ) + agent.context_compressor = compressor + turn = TurnResult( + thread_id="thread-1", + turn_id="normal-turn-1", + compacted=True, + ) + + assert _record_codex_app_server_compaction(agent, turn) is True + assert compressor._fallback_compression_streak == 0 + assert compressor._verify_compaction_cleared_threshold is True diff --git a/tests/run_agent/test_codex_app_server_integration.py b/tests/run_agent/test_codex_app_server_integration.py index df16807ca84e..c88d45c483af 100644 --- a/tests/run_agent/test_codex_app_server_integration.py +++ b/tests/run_agent/test_codex_app_server_integration.py @@ -143,6 +143,13 @@ class TestRunConversationCodexPath: turn_id="turn-compact-1", thread_id="thread-compact-1", compacted=True, + token_usage_last={ + "totalTokens": 300_000, + "inputTokens": 300_000, + "cachedInputTokens": 0, + "outputTokens": 0, + "reasoningOutputTokens": 0, + }, ) monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) @@ -157,8 +164,11 @@ class TestRunConversationCodexPath: assert result["completed"] is True assert agent.context_compressor.compression_count == 1 - assert agent.context_compressor.last_prompt_tokens == -1 - assert agent.context_compressor.awaiting_real_usage_after_compression is True + # A compacted turn with real usage is judged against that same real + # prompt count, exactly like a normal completed compression boundary. + assert agent.context_compressor.last_prompt_tokens == 300_000 + assert agent.context_compressor.awaiting_real_usage_after_compression is False + assert agent.context_compressor._ineffective_compression_count == 1 assert events == [ ( "session:compress", diff --git a/tests/run_agent/test_conversation_fallback_state.py b/tests/run_agent/test_conversation_fallback_state.py new file mode 100644 index 000000000000..c92cee2937c6 --- /dev/null +++ b/tests/run_agent/test_conversation_fallback_state.py @@ -0,0 +1,180 @@ +"""Regression tests for conversation loop fallback state management.""" +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent + + +def _tool_defs(*names): + """Helper: create minimal tool definitions for given names.""" + return [ + { + "type": "function", "function": { + "name": name, + "description": "test tool", + "parameters": {"type": "object", "properties": {}}, + } + } + for name in names + ] + + +def _tool_call(name, call_id): + """Helper: create a minimal tool call object.""" + return SimpleNamespace( + id=call_id, type="function", + function=SimpleNamespace(name=name, arguments="{}"), + ) + + +def _response(*, content, finish_reason, tool_calls=None): + """Helper: create a minimal API response object.""" + message = SimpleNamespace(content=content, tool_calls=tool_calls) + choice = SimpleNamespace(message=message, finish_reason=finish_reason) + return SimpleNamespace(choices=[choice], model="test/model", usage=None) + + +def test_substantive_tool_only_turn_invalidates_older_housekeeping_fallback(): + """ + Regression test for #63860. + + A cached `_last_content_with_tools` response from a housekeeping-only turn + must not survive a later substantive tool-only turn. When the model returns + an empty response after the substantive tool turn, the system should enter + the post-tool nudge path, not use the stale housekeeping fallback. + + Production impact: scheduled cron jobs could return early without + completing their actual work (e.g., daily report job returning a + housekeeping message instead of producing the report artifact). + + Test sequence: + 1. Content + todo (housekeeping) → sets fallback, marks as all-housekeeping + 2. Empty content + web_search (substantive) → should CLEAR old fallback + 3. Empty content, no tool calls → should enter post-tool nudge, not use old fallback + 4. Content "Recovered after nudge." → should be returned as final response + + Before the fix: + - Step 2 would not clear the fallback state (no visible content) + - Step 3 would incorrectly use the housekeeping fallback from step 1 + - API calls would stop at 3, never reaching the nudge response + + After the fix: + - Step 2 classifies tools and clears the fallback because web_search is substantive + - Step 3 enters the post-tool nudge path (no stale housekeeping fallback available) + - Step 4 returns the nudge response as the final answer + """ + with ( + patch("run_agent.get_tool_definitions", return_value=_tool_defs("todo", "web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1/", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent._cached_system_prompt = "You are helpful." + agent._use_prompt_caching = False + agent.tool_delay = 0 + agent.compression_enabled = False + agent.save_trajectories = False + agent.valid_tool_names = {"todo", "web_search"} + agent.client = MagicMock() + agent.client.chat.completions.create.side_effect = [ + # Turn 1: Content + housekeeping tool + _response( + content="I'll begin the work.", + finish_reason="tool_calls", + tool_calls=[_tool_call("todo", "todo1")], + ), + # Turn 2: Empty content + substantive tool (should clear stale fallback) + _response( + content="", + finish_reason="tool_calls", + tool_calls=[_tool_call("web_search", "search1")], + ), + # Turn 3: Empty response (should enter nudge path, not use stale fallback) + _response(content="", finish_reason="stop"), + # Turn 4: Nudge response + _response(content="Recovered after nudge.", finish_reason="stop"), + ] + + with ( + patch("run_agent.handle_function_call", return_value="ok"), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("do the full task") + + assert result["final_response"] == "Recovered after nudge.", ( + f"Expected nudge recovery response, got: {result['final_response']}. " + f"This indicates the stale housekeeping fallback was incorrectly used." + ) + assert result["api_calls"] == 4, ( + f"Expected 4 API calls (including nudge), got: {result['api_calls']}. " + f"This indicates the conversation exited early without retrying." + ) + assert result["turn_exit_reason"].startswith("text_response"), ( + f"Expected text_response exit, got: {result['turn_exit_reason']}. " + f"This indicates the wrong fallback path was taken." + ) + + +def test_housekeeping_only_turn_still_sets_fallback(): + """Regression: pure housekeeping turns (content + only housekeeping tools) + must still set the fallback so the post-response mute path works. This + verifies the fix doesn't break the original use case the fallback was + designed for. + """ + with ( + patch("run_agent.get_tool_definitions", return_value=_tool_defs("memory")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1/", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent._cached_system_prompt = "You are helpful." + agent._use_prompt_caching = False + agent.tool_delay = 0 + agent.compression_enabled = False + agent.save_trajectories = False + agent.valid_tool_names = {"memory"} + agent.client = MagicMock() + agent.client.chat.completions.create.side_effect = [ + # Turn 1: Content + housekeeping tool (should set fallback) + _response( + content="You're welcome!", + finish_reason="tool_calls", + tool_calls=[_tool_call("memory", "mem1")], + ), + # Turn 2: Empty response (should use the housekeeping fallback) + _response(content="", finish_reason="stop"), + ] + + with ( + patch("run_agent.handle_function_call", return_value="ok"), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("save this") + + assert result["final_response"] == "You're welcome!", ( + f"Expected housekeeping fallback content, got: {result['final_response']}. " + f"Pure housekeeping turns should still set the fallback." + ) + assert "fallback_prior_turn_content" in result.get("turn_exit_reason", ""), ( + f"Expected fallback_prior_turn_content exit, got: {result['turn_exit_reason']}." + ) \ No newline at end of file diff --git a/tests/run_agent/test_fallback_reasoning_override.py b/tests/run_agent/test_fallback_reasoning_override.py new file mode 100644 index 000000000000..1c2c3e6228a7 --- /dev/null +++ b/tests/run_agent/test_fallback_reasoning_override.py @@ -0,0 +1,144 @@ +"""Tests for per-model reasoning_effort override during fallback activation. + +Tests that try_activate_fallback re-resolves reasoning_config when +swapping to a fallback model, so per-model overrides are honored even +during error recovery. +""" + +import pytest +from unittest.mock import MagicMock, patch + + +class TestFallbackReasoningOverride: + """Test try_activate_fallback re-resolves reasoning_config.""" + + def test_fallback_re_resolves_reasoning_config(self): + """When fallback activates, reasoning_config should be re-resolved. + + We test the resolution logic directly rather than spinning up a + full try_activate_fallback (which requires extensive agent setup). + The production code calls resolve_per_model_reasoning_effort with + the fallback model string — we verify that works correctly. + """ + from hermes_constants import resolve_per_model_reasoning_effort + + # Simulate: primary was gemini-flash (medium), fallback to claude-opus-4.5 (xhigh) + overrides = { + "claude-opus-4.5": "xhigh", + "gemini-flash": "medium", + } + + # Fallback model lookup + fb_result = resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) + assert fb_result is not None + assert fb_result["effort"] == "xhigh" + + # Primary model lookup (for comparison) + primary_result = resolve_per_model_reasoning_effort("gemini-flash", overrides) + assert primary_result is not None + assert primary_result["effort"] == "medium" + + # The key point: fallback result differs from primary + assert fb_result["effort"] != primary_result["effort"] + + def test_fallback_to_model_without_override_uses_global(self): + """Fallback to a model with no override should resolve to None (→ global).""" + from hermes_constants import resolve_per_model_reasoning_effort + + overrides = {"claude-opus-4.5": "xhigh"} + + # Fallback to gpt-5 which has no override + result = resolve_per_model_reasoning_effort("gpt-5", overrides) + assert result is None # caller falls back to global + + def test_fallback_with_normalized_model_name(self): + """Fallback model name may be normalized (dots→dashes); override should still match.""" + from hermes_constants import resolve_per_model_reasoning_effort + + # User wrote key with dots, but normalize_model_for_provider converts to dashes + overrides = {"claude-sonnet-4.6": "high"} + + result = resolve_per_model_reasoning_effort("claude-sonnet-4-6", overrides) + assert result is not None + assert result["effort"] == "high" + + def test_fallback_recovery_restores_primary_reasoning(self): + """After fallback + restore_primary_runtime, reasoning_config returns to primary's value. + + This tests the integration of Task 6 (_primary_runtime snapshot) with + Task 6b (fallback re-resolution). The full cycle: + 1. Primary model = gemini-flash, reasoning = medium + 2. /model switch → _primary_runtime captures reasoning_config + 3. Fallback activates → reasoning re-resolved for fallback model + 4. restore_primary_runtime → reasoning_config restored from snapshot + """ + from agent.agent_runtime_helpers import restore_primary_runtime + + agent = MagicMock() + # Simulate: _primary_runtime was captured during /model switch + agent._primary_runtime = { + "model": "gemini-flash", + "provider": "google", + "base_url": "", + "api_mode": "openai", + "api_key": "key", + "client_kwargs": {}, + "use_prompt_caching": False, + "use_native_cache_layout": False, + "reasoning_config": {"enabled": True, "effort": "medium"}, + "compressor_model": "gemini-flash", + "compressor_base_url": "", + "compressor_api_key": "", + "compressor_provider": "", + "compressor_context_length": 0, + "compressor_api_mode": "", + "compressor_threshold_tokens": 0, + } + agent._fallback_activated = True + agent._fallback_index = 0 + agent._fallback_chain = [] + agent._fallback_model = None + agent._transport_cache = {} + agent._config_context_length = None + agent._rate_limited_until = 0 + # During fallback, reasoning was changed to xhigh (fallback model's override) + agent.model = "claude-opus-4.5" + agent.provider = "anthropic" + agent.reasoning_config = {"enabled": True, "effort": "xhigh"} + agent.context_compressor = MagicMock() + agent.base_url = "" + agent._anthropic_prompt_cache_policy = MagicMock(return_value=(False, False)) + agent._create_openai_client = MagicMock(return_value=MagicMock()) + agent._ensure_lmstudio_runtime_loaded = MagicMock() + + result = restore_primary_runtime(agent) + assert result is True + # reasoning_config should be restored to primary's value (medium) + assert agent.reasoning_config == {"enabled": True, "effort": "medium"} + + def test_fallback_global_fallback_with_yaml_false(self): + """Fallback global fallback must not coerce YAML boolean False. + + Regression: ``or ""`` turned False into "", silently re-enabling + thinking. The raw value must pass through so + parse_reasoning_effort(False) returns {'enabled': False}. + + The production code in try_activate_fallback does: + _fb_global_effort = _fb_agent_cfg.get("reasoning_effort", "") + agent.reasoning_config = parse_reasoning_effort(_fb_global_effort) + We verify that passing the raw False (not coerced "") produces + the disabled config. + """ + from hermes_constants import parse_reasoning_effort + + # Simulate: no per-model override matches, global is YAML False + _fb_agent_cfg = {"reasoning_effort": False} + + # This is the exact line from try_activate_fallback's else branch. + # The bug was: _fb_global_effort = _fb_agent_cfg.get(...) or "" + # which turned False into "". The fix passes the raw value. + _fb_global_effort = _fb_agent_cfg.get("reasoning_effort", "") + result = parse_reasoning_effort(_fb_global_effort) + + assert result is not None + assert result.get("enabled") is False diff --git a/tests/run_agent/test_fireworks_live.py b/tests/run_agent/test_fireworks_live.py new file mode 100644 index 000000000000..9e2944ef98cf --- /dev/null +++ b/tests/run_agent/test_fireworks_live.py @@ -0,0 +1,64 @@ +"""Live Fireworks smoke test — exercises the Hermes runtime, not a raw SDK client. + +Opt-in only: + HERMES_LIVE_TESTS=1 FIREWORKS_API_KEY=fw_... \\ + pytest tests/run_agent/test_fireworks_live.py -q + +Unlike a bare OpenAI() client pointed at the endpoint, this drives Hermes' +own provider resolution — ``resolve_provider_client('fireworks')`` — so it +verifies the auth/config/base-URL/aux-model wiring that the +bundled provider actually ships, then makes a real call through that client. +""" + +from __future__ import annotations + +import os + +import pytest + +LIVE = os.environ.get("HERMES_LIVE_TESTS") == "1" +FIREWORKS_KEY = os.environ.get("FIREWORKS_API_KEY", "") + +pytestmark = [ + pytest.mark.skipif(not LIVE, reason="live-only: set HERMES_LIVE_TESTS=1"), + pytest.mark.skipif(not FIREWORKS_KEY, reason="FIREWORKS_API_KEY not configured"), + pytest.mark.integration, +] + + +def _resolve_runtime_client(provider="fireworks"): + """Build the Fireworks client the way the Hermes runtime does.""" + from agent.auxiliary_client import resolve_provider_client + + client, model = resolve_provider_client(provider) + assert client is not None, "Hermes failed to build a Fireworks client" + return client, model + + +def test_hermes_wires_fireworks_client(): + """The runtime resolves a Fireworks client pointed at the right endpoint + with the partner-attribution headers applied — no network required.""" + client, model = _resolve_runtime_client() + assert "api.fireworks.ai" in str(client.base_url) + # Default aux model must be a PAYG /models/ id (works with an fw_ key). + assert model.startswith("accounts/fireworks/models/") + + +def test_fireworks_basic_chat_through_runtime(): + """A single-turn completion via the Hermes-resolved client returns text.""" + client, model = _resolve_runtime_client() + + response = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": "Say exactly the word 'pong' and nothing else."}], + timeout=60, + ) + + content = response.choices[0].message.content + assert content and "pong" in content.lower() + + +def test_fireworks_alias_resolves_through_runtime(): + """The 'fw' alias resolves to the same Fireworks client via the runtime.""" + client, _ = _resolve_runtime_client("fw") + assert "api.fireworks.ai" in str(client.base_url) diff --git a/tests/run_agent/test_infinite_compaction_loop.py b/tests/run_agent/test_infinite_compaction_loop.py index 31a7a12ba16e..52a2efa813ab 100644 --- a/tests/run_agent/test_infinite_compaction_loop.py +++ b/tests/run_agent/test_infinite_compaction_loop.py @@ -34,6 +34,9 @@ def _make_compressor(**kwargs) -> ContextCompressor: quiet_mode=True, ) defaults.update(kwargs) + # NOTE: 96K < 512K, so the small-context floor raises the effective + # threshold_percent to 0.75 → threshold_tokens = 72_000. Tests use + # 73_000 as the "over threshold" probe value. with patch("agent.context_compressor.get_model_context_length", return_value=96000): return ContextCompressor(**defaults) @@ -68,14 +71,14 @@ class TestCompressNoOpRegistersIneffective: ) # A large session that passes the min_for_compress check messages = _build_session(10, words_per_turn=10) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 # Mock _find_tail_cut_by_tokens to return head_end, # causing compress_start >= compress_end original = comp._find_tail_cut_by_tokens comp._find_tail_cut_by_tokens = lambda msgs, he: he # force no-op - result = comp.compress(messages, current_tokens=65_000) + result = comp.compress(messages, current_tokens=73_000) assert comp._ineffective_compression_count >= 1, ( f"Expected ineffective_compression_count >= 1, got {comp._ineffective_compression_count}" @@ -88,10 +91,10 @@ class TestCompressNoOpRegistersIneffective: config_context_length=96000, ) messages = _build_session(10, words_per_turn=10) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._find_tail_cut_by_tokens = lambda msgs, he: he # force no-op - comp.compress(messages, current_tokens=65_000) + comp.compress(messages, current_tokens=73_000) assert comp._last_compression_savings_pct == 0.0 @@ -102,14 +105,14 @@ class TestCompressNoOpRegistersIneffective: config_context_length=96000, ) messages = _build_session(10, words_per_turn=10) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._find_tail_cut_by_tokens = lambda msgs, he: he # force no-op - comp.compress(messages, current_tokens=65_000) - comp.compress(messages, current_tokens=65_000) + comp.compress(messages, current_tokens=73_000) + comp.compress(messages, current_tokens=73_000) assert comp._ineffective_compression_count >= 2 - assert not comp.should_compress(65_000), ( + assert not comp.should_compress(73_000), ( "should_compress should return False after 2+ ineffective compressions" ) @@ -120,11 +123,11 @@ class TestCompressNoOpRegistersIneffective: config_context_length=96000, ) messages = _build_session(10, words_per_turn=10) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 original_cut = comp._find_tail_cut_by_tokens comp._find_tail_cut_by_tokens = lambda msgs, he: he # force no-op - result = comp.compress(messages, current_tokens=65_000) + result = comp.compress(messages, current_tokens=73_000) assert len(result) == len(messages), ( f"Expected unchanged message count {len(messages)}, got {len(result)}" @@ -214,9 +217,9 @@ class TestEffectiveCompressionResetsCounter: ) messages = _build_session(30, words_per_turn=100) comp._generate_summary = MagicMock(return_value="Compacted summary of earlier turns.") - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 - comp.compress(messages, current_tokens=65_000) + comp.compress(messages, current_tokens=73_000) assert comp._ineffective_compression_count == 0, ( f"Expected 0 ineffective compressions with effective compression, " @@ -234,16 +237,16 @@ class TestAntiThrashing: def test_ineffective_count_2_blocks(self): """_ineffective_compression_count >= 2 -> should_compress returns False.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._ineffective_compression_count = 2 - assert not comp.should_compress(65_000) + assert not comp.should_compress(73_000) def test_ineffective_count_1_allows(self): """_ineffective_compression_count = 1 -> should_compress still True.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._ineffective_compression_count = 1 - assert comp.should_compress(65_000) + assert comp.should_compress(73_000) def test_below_threshold_allows(self): """Tokens below threshold -> should_compress returns False regardless.""" @@ -266,23 +269,23 @@ class TestCooldownGuard: """A future cooldown deadline -> should_compress returns False even when tokens are over threshold.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._summary_failure_cooldown_until = time.monotonic() + 60 - assert not comp.should_compress(65_000) + assert not comp.should_compress(73_000) def test_expired_cooldown_allows(self): """A past cooldown deadline -> compression resumes normally.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._summary_failure_cooldown_until = time.monotonic() - 1 - assert comp.should_compress(65_000) + assert comp.should_compress(73_000) def test_no_cooldown_allows(self): """The default (no cooldown set) does not block compression.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 assert comp._summary_failure_cooldown_until == 0.0 - assert comp.should_compress(65_000) + assert comp.should_compress(73_000) # --------------------------------------------------------------------------- diff --git a/tests/run_agent/test_malformed_tool_arguments.py b/tests/run_agent/test_malformed_tool_arguments.py new file mode 100644 index 000000000000..6736182d3043 --- /dev/null +++ b/tests/run_agent/test_malformed_tool_arguments.py @@ -0,0 +1,98 @@ +"""Malformed model tool arguments are rejected at the dispatch boundary.""" + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent + + +def _make_agent() -> AIAgent: + tool_defs = [ + { + "type": "function", + "function": { + "name": "web_search", + "description": "search", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + with ( + patch("run_agent.get_tool_definitions", return_value=tool_defs), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("hermes_cli.config.load_config", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.client = MagicMock() + agent.tool_delay = 0 + agent._flush_messages_to_session_db = MagicMock() + return agent + + +def _tool_call(call_id: str, arguments: str): + return SimpleNamespace( + id=call_id, + type="function", + function=SimpleNamespace(name="web_search", arguments=arguments), + ) + + +@pytest.mark.parametrize("dispatch_mode", ["sequential", "concurrent"]) +@pytest.mark.parametrize( + "bad_arguments", + [ + pytest.param("not-json", id="malformed-json"), + pytest.param('"scalar"', id="scalar"), + pytest.param("[]", id="list"), + pytest.param("", id="empty"), + pytest.param('{"query": "cut off', id="truncated"), + ], +) +def test_malformed_arguments_are_rejected_without_blocking_valid_sibling( + dispatch_mode: str, + bad_arguments: str, +): + agent = _make_agent() + assistant_message = SimpleNamespace( + content="", + tool_calls=[ + _tool_call("call-bad", bad_arguments), + _tool_call("call-good", '{"query": "valid"}'), + ], + ) + messages = [] + executed = [] + + def fake_dispatch(name, args, task_id, *positional, **kwargs): + call_id = kwargs.get("tool_call_id") or (positional[0] if positional else None) + executed.append((name, args, call_id)) + return json.dumps({"ok": args["query"]}) + + with ( + patch("run_agent.handle_function_call", side_effect=fake_dispatch), + patch.object(agent, "_invoke_tool", side_effect=fake_dispatch), + patch( + "agent.tool_executor.maybe_persist_tool_result", + side_effect=lambda **kwargs: kwargs["content"], + ), + ): + execute = getattr(agent, f"_execute_tool_calls_{dispatch_mode}") + execute(assistant_message, messages, "task-1") + + assert executed == [("web_search", {"query": "valid"}, "call-good")] + assert [message["tool_call_id"] for message in messages] == ["call-bad", "call-good"] + assert len([message for message in messages if message["tool_call_id"] == "call-bad"]) == 1 + + assert '"error": "Invalid tool arguments"' in messages[0]["content"] + assert "JSON object" in messages[0]["content"] + assert json.loads(messages[1]["content"]) == {"ok": "valid"} diff --git a/tests/run_agent/test_moa_loop_mode.py b/tests/run_agent/test_moa_loop_mode.py index 04c7d51ceb10..1c1ab8f71eb7 100644 --- a/tests/run_agent/test_moa_loop_mode.py +++ b/tests/run_agent/test_moa_loop_mode.py @@ -392,6 +392,44 @@ def test_reference_messages_fresh_user_turn_ends_on_that_user(): assert view[-1] == {"role": "user", "content": "q2 current"} +def test_reference_messages_drops_empty_user_turns(): + """Empty user turns must not leak into the advisory view. + + A user message whose content is "" or a non-string/multimodal payload + (flattened to "" by the text-extraction step) carries nothing advisory. + Strict providers (Kimi/Moonshot and others that enforce non-empty user + content) reject such a message with + 400 "message ... with role 'user' must not be empty", while lenient + providers (DeepSeek) accept it — so a fan-out over the identical rendered + view fails on one reference and passes on another. The renderer must emit + NO empty user turn, mirroring how empty assistant turns are dropped. + """ + from agent.moa_loop import _reference_messages + + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "real question"}, + {"role": "assistant", "content": "", "tool_calls": [ + {"function": {"name": "read_file", "arguments": '{"path":"c.yaml"}'}} + ]}, + {"role": "tool", "content": "some result"}, + {"role": "user", "content": ""}, # empty string user turn + {"role": "user", "content": [{"type": "text", "text": "multimodal"}]}, # non-string -> "" + ] + + view = _reference_messages(messages) + + # No user turn in the view may be empty/whitespace-only. + empty_users = [ + m for m in view + if m.get("role") == "user" and not str(m.get("content", "")).strip() + ] + assert empty_users == [], f"empty user turn leaked into advisory view: {empty_users}" + # The real user prompt survives and the view still ends on a user turn. + assert view[0] == {"role": "user", "content": "real question"} + assert view[-1]["role"] == "user" + + def test_run_reference_prepends_advisory_system_prompt(monkeypatch): """Each reference call gets the advisory-role system prompt first. @@ -1059,3 +1097,171 @@ def test_reference_guidance_merges_into_trailing_user_in_plain_chat(): assert len(messages) == 2 assert messages[-1]["role"] == "user" assert messages[-1]["content"] == "hello\n\nREFERENCE BLOCK" + + +def test_reference_messages_flattens_cache_decorated_content(): + """Cache-decorated turns (content-part lists) must not blind the references. + + conversation_loop runs apply_anthropic_cache_control BEFORE the MoA facade + when the preset's aggregator is a cache-honoring Claude route (post-#57675). + That converts string content into [{"type": "text", "text": ..., + "cache_control": ...}] lists. The advisory view previously read only string + content, so the user's ENTIRE prompt flattened to "" — Claude references + then 400'd ("messages: at least one message is required") while tolerant + models answered "no user request is present" (live incident, Jul 14 2026, + preset "closed", session 20260714_001520_28157b). + """ + from agent.moa_loop import _reference_messages + from agent.prompt_caching import apply_anthropic_cache_control + + plain = [ + {"role": "system", "content": "hermes system prompt"}, + {"role": "user", "content": "Can we get codex usage resets into hermes?"}, + ] + decorated = apply_anthropic_cache_control(plain, native_anthropic=False) + # Premise: decoration really converts the user turn to a content-part list. + assert isinstance(decorated[1]["content"], list) + + view = _reference_messages(decorated) + + assert view == [ + {"role": "user", "content": "Can we get codex usage resets into hermes?"} + ] + # Invariant: decorated and undecorated transcripts produce the SAME + # advisory view — so decoration can never change what references see, + # and the advisory prefix stays byte-stable for advisor prompt caching. + assert view == _reference_messages(plain) + + +def test_reference_messages_flattens_multimodal_user_turn(): + """Multimodal user turns (text + image parts) keep their text in the view. + + Image parts carry no advisory text and are skipped; the text part must + survive. Previously the whole turn flattened to "". + """ + from agent.moa_loop import _reference_messages + + messages = [ + {"role": "user", "content": [ + {"type": "text", "text": "what is in this screenshot?"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + ]}, + ] + + view = _reference_messages(messages) + + assert view == [{"role": "user", "content": "what is in this screenshot?"}] + # No base64 payload leaks into the advisory view. + assert all("base64" not in m["content"] for m in view) + + +def test_reference_messages_image_only_user_turn_gets_placeholder(): + """An image-only user turn must not become an empty user message. + + Anthropic rejects empty text blocks (the original 400 class) and silently + skipping the turn would misalign user/assistant alternation in the view — + so a placeholder stands in for the non-text content. + """ + from agent.moa_loop import _reference_messages + + messages = [ + {"role": "user", "content": [ + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + ]}, + {"role": "assistant", "content": "I see a diagram."}, + {"role": "user", "content": "now explain it"}, + ] + + view = _reference_messages(messages) + + assert view[0]["role"] == "user" + assert view[0]["content"].strip(), "image-only turn must not be empty" + assert "non-text" in view[0]["content"] + assert view[-1] == {"role": "user", "content": "now explain it"} + + +def test_reference_messages_flattens_structured_assistant_and_tool_content(): + """Assistant and tool turns with content-part lists are flattened too. + + Multimodal tool results (e.g. computer_use screenshots) and adapter-shaped + assistant turns arrive as lists; their text must reach the references and + their image parts must not leak. + """ + from agent.moa_loop import _reference_messages + + messages = [ + {"role": "user", "content": "check the screen"}, + { + "role": "assistant", + "content": [{"type": "text", "text": "taking a screenshot"}], + "tool_calls": [{"id": "c1", "function": {"name": "capture", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "c1", "content": [ + {"type": "text", "text": "screenshot captured: login page visible"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,BBBB"}}, + ]}, + ] + + view = _reference_messages(messages) + + joined = "\n".join(m["content"] for m in view) + assert "taking a screenshot" in joined + assert "[called tool: capture(" in joined + assert "[tool result: screenshot captured: login page visible]" in joined + assert "BBBB" not in joined + assert view[-1]["role"] == "user" + + +def test_reference_guidance_appends_text_part_to_decorated_trailing_user(): + """A cache-decorated trailing user turn still receives the guidance block. + + Decoration converts the trailing user turn to a content-part list; the + guidance must be appended as a NEW text part AFTER the cache_control-marked + part (cached prefix stays byte-stable, no consecutive-user-turn 400s), not + silently dropped and not added as a second user message. + """ + from agent.moa_loop import _attach_reference_guidance + + marked_part = { + "type": "text", + "text": "hello", + "cache_control": {"type": "ephemeral"}, + } + messages = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": [dict(marked_part)]}, + ] + _attach_reference_guidance(messages, "REFERENCE BLOCK") + + # No extra message (would break user/user alternation). + assert len(messages) == 2 + content = messages[-1]["content"] + assert isinstance(content, list) and len(content) == 2 + # The cache-marked part is byte-identical (prefix stability). + assert content[0] == marked_part + # The guidance rides as a trailing text part outside the cached span. + assert content[1] == {"type": "text", "text": "\n\nREFERENCE BLOCK"} + + +def test_reference_messages_drops_whitespace_only_string_user_turn(): + """A whitespace-only STRING user turn is dropped, not placeholdered. + + The non-text placeholder exists for structured content (image-only turns) + where a real turn happened that the reference should know about. A bare + whitespace string carries nothing — emitting it would 400 strict + providers (Kimi/Moonshot 'role user must not be empty'), and + placeholdering it would fabricate an attachment that never existed. + """ + from agent.moa_loop import _reference_messages + + messages = [ + {"role": "user", "content": " "}, + {"role": "assistant", "content": "a"}, + {"role": "user", "content": "real"}, + ] + + view = _reference_messages(messages) + + assert view[0] == {"role": "assistant", "content": "a"} + assert view[-1] == {"role": "user", "content": "real"} + assert all(str(m["content"]).strip() for m in view) diff --git a/tests/run_agent/test_primary_runtime_restore.py b/tests/run_agent/test_primary_runtime_restore.py index d1ac56dca6b3..0d732277e977 100644 --- a/tests/run_agent/test_primary_runtime_restore.py +++ b/tests/run_agent/test_primary_runtime_restore.py @@ -286,15 +286,47 @@ class TestRestorePrimaryRuntime: agent._credential_pool = _DeepseekPool() agent._swap_credential = MagicMock() - with patch("run_agent.OpenAI", return_value=MagicMock()): + primary_pool = MagicMock() + primary_pool.provider = primary_provider + primary_pool.has_available.return_value = False + with ( + patch("run_agent.OpenAI", return_value=MagicMock()), + patch("agent.credential_pool.load_pool", return_value=primary_pool) as load_pool, + ): result = agent._restore_primary_runtime() assert result is True assert agent.provider == primary_provider assert agent.base_url == primary_base_url assert "deepseek" not in str(agent.base_url) + assert agent._credential_pool is primary_pool + load_pool.assert_called_once_with(primary_provider) agent._swap_credential.assert_not_called() + def test_restore_clears_fallback_pool_when_primary_pool_reload_fails(self): + """A fallback pool must never remain attached to the restored primary.""" + agent = _make_agent( + provider="openai-api", + base_url="https://api.openai.com/v1", + ) + agent._fallback_activated = True + fallback_pool = MagicMock() + fallback_pool.provider = "deepseek" + agent._credential_pool = fallback_pool + + with ( + patch("run_agent.OpenAI", return_value=MagicMock()), + patch( + "agent.credential_pool.load_pool", + side_effect=RuntimeError("auth store unavailable"), + ), + ): + result = agent._restore_primary_runtime() + + assert result is True + assert agent.provider == "openai-api" + assert agent._credential_pool is None + def test_restore_swaps_matching_custom_pool_entry(self): """Custom primary + custom: entry whose base_url resolves to the SAME custom key must swap (legitimate same-endpoint rotation).""" diff --git a/tests/run_agent/test_retry_status_buffer.py b/tests/run_agent/test_retry_status_buffer.py index 221c10c75962..bf116f177b71 100644 --- a/tests/run_agent/test_retry_status_buffer.py +++ b/tests/run_agent/test_retry_status_buffer.py @@ -135,6 +135,86 @@ def test_mixed_kinds_replay_through_correct_channels(): assert warns == ["warn-1"] +def test_pending_fallback_notice_emitted_once_on_success(): + """On successful recovery the one-shot fallback notice is surfaced even + though the noisy retry buffer is dropped.""" + agent = _make_bare_agent() + emitted = [] + agent._emit_status = lambda msg: emitted.append(msg) + + # Simulate try_activate_fallback: buffer the noisy switch line AND record + # the durable one-shot notice. + agent._buffer_status("🔄 Primary model failed — switching to fallback: m2 via p2") + agent._pending_fallback_notice = "🔄 Switched to fallback model: m1 via p1 → m2 via p2" + + # Success path order: emit pending notice, then drop the buffer. + agent._emit_pending_fallback_notice() + agent._clear_status_buffer() + + # The durable notice was shown exactly once; the buffered retry noise was + # silently dropped. + assert emitted == ["🔄 Switched to fallback model: m1 via p1 → m2 via p2"] + assert agent._retry_status_buffer == [] + # Notice is cleared so it cannot re-emit on a later turn. + assert agent._pending_fallback_notice is None + + # A second success path with no new fallback emits nothing. + agent._emit_pending_fallback_notice() + assert emitted == ["🔄 Switched to fallback model: m1 via p1 → m2 via p2"] + + +def test_pending_fallback_notice_noop_when_unset(): + """No fallback this turn → no notice emitted on the success path.""" + agent = _make_bare_agent() + emitted = [] + agent._emit_status = lambda msg: emitted.append(msg) + + # No _pending_fallback_notice attribute set at all. + agent._emit_pending_fallback_notice() + assert emitted == [] + + +def test_flush_discards_pending_fallback_notice(): + """On terminal failure the flushed buffer already carries the switch line, + so the one-shot notice is discarded to avoid a stale duplicate later.""" + agent = _make_bare_agent() + emitted = [] + agent._emit_status = lambda msg: emitted.append(msg) + + agent._buffer_status("🔄 Primary model failed — switching to fallback: m2 via p2") + agent._pending_fallback_notice = "🔄 Switched to fallback model: m1 via p1 → m2 via p2" + + # Terminal failure flushes the buffered trace... + agent._flush_status_buffer() + assert emitted == ["🔄 Primary model failed — switching to fallback: m2 via p2"] + # ...and discards the pending notice so it won't re-emit on a later turn. + assert agent._pending_fallback_notice is None + + emitted.clear() + agent._emit_pending_fallback_notice() + assert emitted == [] + + +def test_pending_fallback_notice_survives_emit_callback_error(): + """A failing status callback must not leave the notice set for a stale + re-emit, and must not raise.""" + agent = _make_bare_agent() + seen = [] + + def boom(msg): + seen.append(msg) + raise RuntimeError("simulated callback failure") + + agent._emit_status = boom + agent._pending_fallback_notice = "🔄 Switched to fallback model: m1 via p1 → m2 via p2" + + # Should not raise. + agent._emit_pending_fallback_notice() + # Attempt was made and the notice is cleared regardless. + assert seen == ["🔄 Switched to fallback model: m1 via p1 → m2 via p2"] + assert agent._pending_fallback_notice is None + + def test_flush_swallows_callback_exceptions(): agent = _make_bare_agent() seen = [] diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 171a7c11255d..2dc46c1d785b 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -11,6 +11,7 @@ import io import json import logging import re +import threading import uuid from logging.handlers import RotatingFileHandler from pathlib import Path @@ -143,6 +144,100 @@ def test_persist_user_message_override_preserves_multimodal_turns(agent): assert messages == [{"role": "user", "content": multimodal_content}] +def test_persist_user_message_override_restores_clean_multimodal_note(agent): + clean_content = [ + {"type": "text", "text": "Describe this screenshot"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + ] + api_content = [ + {"type": "text", "text": "[MODEL SWITCH NOTE]\n\nDescribe this screenshot"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + ] + messages = [{"role": "user", "content": api_content}] + agent._persist_user_message_idx = 0 + agent._persist_user_message_override = clean_content + + agent._apply_persist_user_message_override(messages) + + assert messages == [{"role": "user", "content": clean_content}] + + +def test_flush_persist_override_replaces_api_local_multimodal_note(agent): + """A note-added multimodal API payload stores the original clean content.""" + clean_content = [ + {"type": "text", "text": "Describe this screenshot"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + ] + api_content = [ + {"type": "text", "text": "[MODEL SWITCH NOTE]\n\nDescribe this screenshot"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + ] + agent._session_db = MagicMock() + agent._session_db_created = True + agent.session_id = "session-123" + agent._last_flushed_db_idx = 0 + agent._persist_user_message_idx = 0 + agent._persist_user_message_override = clean_content + agent._persist_user_message_timestamp = None + + agent._flush_messages_to_session_db([{"role": "user", "content": api_content}], []) + + db_write = agent._session_db.append_message.call_args.kwargs + assert db_write["content"] == "Describe this screenshot\n[screenshot]" + assert api_content[0]["text"] == "[MODEL SWITCH NOTE]\n\nDescribe this screenshot" + + +def test_direct_session_db_flushes_share_marker_claim(agent): + """A direct flush cannot interleave its marker check with `_persist_session`.""" + class _BarrierDB: + def __init__(self): + self.rows = [] + self.entered = threading.Event() + self.release = threading.Event() + self.calls = 0 + self._lock = threading.Lock() + + def append_message(self, **kwargs): + with self._lock: + self.calls += 1 + first = self.calls == 1 + if first: + self.entered.set() + assert self.release.wait(timeout=5) + self.rows.append(kwargs["content"]) + + db = _BarrierDB() + agent._session_db = db + agent._session_db_created = True + agent.session_id = "session-123" + agent._last_flushed_db_idx = 0 + agent._flushed_db_message_ids = set() + agent._flushed_db_message_session_id = None + agent._persist_user_message_idx = None + agent._persist_user_message_override = None + agent._persist_user_message_timestamp = None + agent._persist_disabled = False + agent._session_persist_lock = threading.RLock() + agent._session_json_enabled = False + + message = {"role": "user", "content": "exactly once"} + normal = threading.Thread(target=lambda: agent._persist_session([message], [])) + direct = threading.Thread(target=lambda: agent._flush_messages_to_session_db([message], [])) + normal.start() + assert db.entered.wait(timeout=5) + direct.start() + # Direct flush is blocked by the agent-wide persistence lock until the + # normal writer stamps the message's durable marker. + assert db.calls == 1 + db.release.set() + normal.join(timeout=5) + direct.join(timeout=5) + + assert not normal.is_alive() + assert not direct.is_alive() + assert db.rows == ["exactly once"] + + @pytest.fixture() def agent_with_memory_tool(): """Agent whose valid_tool_names includes 'memory'.""" @@ -2333,7 +2428,7 @@ class TestExecuteToolCalls: or "interrupted" in messages[0]["content"].lower() ) - def test_invalid_json_args_defaults_empty(self, agent): + def test_invalid_json_args_are_rejected_without_dispatch(self, agent): tc = _mock_tool_call( name="web_search", arguments="not valid json", call_id="c1" ) @@ -2341,13 +2436,30 @@ class TestExecuteToolCalls: messages = [] with patch("run_agent.handle_function_call", return_value="ok") as mock_hfc: agent._execute_tool_calls(mock_msg, messages, "task-1") - # Invalid JSON args should fall back to empty dict - args, kwargs = mock_hfc.call_args - assert args[:3] == ("web_search", {}, "task-1") - assert set(kwargs.get("enabled_tools", [])) == agent.valid_tool_names + mock_hfc.assert_not_called() assert len(messages) == 1 assert messages[0]["role"] == "tool" assert messages[0]["tool_call_id"] == "c1" + assert "valid json object" in messages[0]["content"].lower() + assert "tool was not executed" in messages[0]["content"].lower() + + def test_none_args_rejected_without_dispatch(self, agent): + """None arguments must not crash the dispatch path. Current contract: + malformed (non-string, non-JSON-object) args are rejected without + executing the tool — same as invalid JSON strings. The mainline + run_conversation path normalizes None to "{}" BEFORE dispatch (see + test_tool_call_none_args_verbose_logging_does_not_crash), so this + direct-dispatch path only needs to degrade gracefully, not coerce.""" + tc = _mock_tool_call(name="web_search", arguments=None, call_id="c1") + mock_msg = _mock_assistant_msg(content="", tool_calls=[tc]) + messages = [] + with patch("run_agent.handle_function_call", return_value="ok") as mock_hfc: + agent._execute_tool_calls(mock_msg, messages, "task-1") + mock_hfc.assert_not_called() + assert len(messages) == 1 + assert messages[0]["role"] == "tool" + assert messages[0]["tool_call_id"] == "c1" + assert "tool was not executed" in messages[0]["content"].lower() def test_result_truncation_over_100k(self, agent, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) @@ -2613,6 +2725,20 @@ class TestConcurrentToolExecution: mock_seq.assert_called_once() mock_con.assert_not_called() + def test_none_args_batch_does_not_crash_parallelism_gating(self, agent): + """Non-string tool arguments must not crash the segment planner — + the None-args call becomes a sequential barrier and the batch + dispatches without raising.""" + tc1 = _mock_tool_call(name="web_search", arguments='{}', call_id="c1") + tc2 = _mock_tool_call(name="web_search", arguments=None, call_id="c2") + mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2]) + messages = [] + with patch.object(agent, "_execute_tool_calls_sequential") as mock_seq: + with patch.object(agent, "_execute_tool_calls_concurrent") as mock_con: + agent._execute_tool_calls(mock_msg, messages, "task-1") + mock_seq.assert_called_once() + mock_con.assert_not_called() + def test_non_dict_args_forces_sequential(self, agent): """Tool arguments that parse to a non-dict type should fall back to sequential.""" tc1 = _mock_tool_call(name="web_search", arguments='{}', call_id="c1") @@ -2625,6 +2751,22 @@ class TestConcurrentToolExecution: mock_seq.assert_called_once() mock_con.assert_not_called() + def test_dict_args_batch_forces_sequential_without_crash(self, agent): + """Pre-parsed dict arguments (non-string) must not crash the planner. + Current contract: the mainline loop normalizes dict args to JSON + strings BEFORE dispatch, so raw dicts reaching the gate are treated + as barriers (defensive sequential), consistent with the executors + rejecting non-string args rather than repairing them.""" + tc1 = _mock_tool_call(name="web_search", arguments={"q": "alpha"}, call_id="c1") + tc2 = _mock_tool_call(name="web_search", arguments={"q": "beta"}, call_id="c2") + mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2]) + messages = [] + with patch.object(agent, "_execute_tool_calls_sequential") as mock_seq: + with patch.object(agent, "_execute_tool_calls_concurrent") as mock_con: + agent._execute_tool_calls(mock_msg, messages, "task-1") + mock_seq.assert_called_once() + mock_con.assert_not_called() + def test_concurrent_executes_all_tools(self, agent): """Concurrent path should execute all tools and append results in order.""" tc1 = _mock_tool_call(name="web_search", arguments='{"q":"alpha"}', call_id="c1") @@ -2654,6 +2796,29 @@ class TestConcurrentToolExecution: assert "beta" in messages[1]["content"] assert "gamma" in messages[2]["content"] + def test_concurrent_none_args_rejected_without_crash(self, agent): + """Concurrent executor must not crash on arguments=None. Current + contract (_parse_tool_arguments): non-object args are rejected with + a structured error result and the tool is not executed; the valid + sibling still runs. One result per call, in order.""" + tc1 = _mock_tool_call(name="web_search", arguments=None, call_id="c1") + tc2 = _mock_tool_call(name="web_search", arguments='{"q":"ok"}', call_id="c2") + mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2]) + messages = [] + seen_args = [] + + def fake_handle(name, args, task_id, **kwargs): + seen_args.append((kwargs["tool_call_id"], args)) + return "ok" + + with patch("run_agent.handle_function_call", side_effect=fake_handle): + agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1") + + # Only the valid call executed; the None-args call was rejected. + assert seen_args == [("c2", {"q": "ok"})] + assert [m["tool_call_id"] for m in messages] == ["c1", "c2"] + assert "tool was not executed" in messages[0]["content"].lower() + def test_concurrent_preserves_order_despite_timing(self, agent): """Even if tools finish in different order, messages should be in original order.""" import time as _time @@ -2776,6 +2941,7 @@ class TestConcurrentToolExecution: assert "fast-result" in messages[0]["content"] assert messages[1]["tool_call_id"] == "c2" assert "timed out after" in messages[1]["content"] + assert messages[1]["effect_disposition"] == "unknown" assert [batch[-1]["tool_call_id"] for batch in flushed] == ["c1", "c2"] assert "fast-result" in flushed[0][-1]["content"] assert "timed out after" in flushed[1][-1]["content"] @@ -3789,6 +3955,48 @@ class TestHandleMaxIterations: kwargs = agent.client.chat.completions.create.call_args.kwargs assert kwargs["extra_body"]["provider"]["only"] == ["Anthropic"] + def test_summary_keeps_provider_preferences_for_nous(self, agent): + agent.base_url = "https://proxy.example.com/v1" + agent._base_url_lower = agent.base_url.lower() + agent.provider = "nous" + agent.providers_allowed = ["deepseek"] + agent.providers_ignored = ["deepinfra"] + agent.provider_sort = "throughput" + agent.provider_require_parameters = True + agent.provider_data_collection = "deny" + agent.client.chat.completions.create.return_value = _mock_response(content="Summary") + agent._cached_system_prompt = "You are helpful." + + result = agent._handle_max_iterations([{"role": "user", "content": "do stuff"}], 60) + + assert result == "Summary" + kwargs = agent.client.chat.completions.create.call_args.kwargs + from agent.portal_tags import nous_portal_tags + + assert kwargs["extra_body"]["tags"] == nous_portal_tags() + assert kwargs["extra_body"]["provider"] == { + "only": ["deepseek"], + "ignore": ["deepinfra"], + "sort": "throughput", + "require_parameters": True, + "data_collection": "deny", + } + + def test_summary_keeps_nous_profile_body_without_routing_preferences(self, agent): + agent.base_url = "https://proxy.example.com/v1" + agent._base_url_lower = agent.base_url.lower() + agent.provider = "nous" + agent.client.chat.completions.create.return_value = _mock_response(content="Summary") + agent._cached_system_prompt = "You are helpful." + + result = agent._handle_max_iterations([{"role": "user", "content": "do stuff"}], 60) + + assert result == "Summary" + kwargs = agent.client.chat.completions.create.call_args.kwargs + from agent.portal_tags import nous_portal_tags + + assert kwargs["extra_body"] == {"tags": nous_portal_tags()} + def test_summary_drops_invalid_provider_sort(self, agent): agent.base_url = "https://openrouter.ai/api/v1" agent._base_url_lower = agent.base_url.lower() @@ -3941,6 +4149,66 @@ class TestRunConversation: assert result["final_response"] == "Final answer" assert result["completed"] is True + def test_codex_content_filter_incomplete_routes_to_policy_fallback(self, agent): + self._setup_agent(agent) + agent.api_mode = "codex_responses" + agent.provider = "openai-codex" + agent.base_url = "https://chatgpt.com/backend-api/codex" + agent._base_url_lower = agent.base_url.lower() + agent._base_url_hostname = "chatgpt.com" + agent.model = "gpt-5.5" + agent._fallback_chain = [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.7"}, + ] + agent._fallback_index = 0 + + content_filter_response = SimpleNamespace( + status="incomplete", + incomplete_details=SimpleNamespace(reason="content_filter"), + output=[], + output_text="", + model="gpt-5.5", + usage=None, + ) + fallback_response = SimpleNamespace( + status="completed", + incomplete_details=None, + output=[ + SimpleNamespace( + type="message", + status="completed", + content=[SimpleNamespace(type="output_text", text="Recovered on fallback")], + ) + ], + model="fallback/model", + usage=None, + ) + hook_events = [] + + def _fake_activate(reason=None): + agent._fallback_index = len(agent._fallback_chain) + return True + + with ( + patch.object(agent, "_create_request_openai_client", return_value=MagicMock()), + patch.object(agent, "_close_request_openai_client"), + patch.object(agent, "_run_codex_stream", side_effect=[content_filter_response, fallback_response]) as mock_run_codex_stream, + patch.object(agent, "_try_activate_fallback", side_effect=_fake_activate) as mock_try_activate_fallback, + patch.object(agent, "_invoke_api_request_error_hook", side_effect=lambda **kw: hook_events.append(kw)), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("summarize this large Slack thread") + + assert result["final_response"] == "Recovered on fallback" + assert result["completed"] is True + mock_try_activate_fallback.assert_called_once_with() + assert mock_run_codex_stream.call_count == 2 + assert hook_events[0]["error_type"] == "ContentPolicyBlocked" + assert hook_events[0]["retryable"] is False + assert hook_events[0]["reason"] == FailoverReason.content_policy_blocked.value + def test_ollama_small_runtime_context_fails_before_api_call(self, agent, caplog): self._setup_agent(agent) agent.model = "qwen3.5:9b" @@ -3984,6 +4252,25 @@ class TestRunConversation: assert mock_handle_function_call.call_args.kwargs["tool_call_id"] == "c1" assert mock_handle_function_call.call_args.kwargs["session_id"] == agent.session_id + def test_tool_call_none_args_verbose_logging_does_not_crash(self, agent): + self._setup_agent(agent) + agent.verbose_logging = True + tc = _mock_tool_call(name="web_search", arguments=None, call_id="c1") + resp1 = _mock_response(content="", finish_reason="tool_calls", tool_calls=[tc]) + resp2 = _mock_response(content="Done searching", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [resp1, resp2] + + with ( + patch("run_agent.handle_function_call", return_value="search result") as mock_handle_function_call, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("search something") + + assert result["final_response"] == "Done searching" + assert mock_handle_function_call.call_args.args[:2] == ("web_search", {}) + def test_request_scoped_api_hooks_fire_for_each_api_call(self, agent): self._setup_agent(agent) tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1") @@ -5313,6 +5600,281 @@ class TestRunConversation: "_record_task_failure should not be called outside kanban mode" ) + # ── Output-cap retry: safe_out uses provider available_out + request estimate ── + + def test_output_cap_retry_uses_provider_available_out(self, agent): + """run_conversation retries an output-cap error with max_tokens <= + available_out - 64, and does NOT halve context_length or trigger + compression. + """ + self._setup_agent(agent) + agent.api_mode = "chat_completions" + agent.provider = "openrouter" + agent.model = "some/model" + agent.max_tokens = 65_536 + agent.compression_enabled = True + agent.context_compressor.context_length = 200_000 + agent.context_compressor.should_compress = MagicMock(return_value=False) + + error_msg = ( + "max_tokens: 65536 > context_window: 200000 " + "- input_tokens: 199000 = available_tokens: 1000" + ) + exc = Exception(error_msg) + exc.status_code = 400 + exc.code = 400 + + ok_resp = _mock_response(content="done", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [exc, ok_resp] + + mock_compress = MagicMock() + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent.context_compressor, "update_model"), + patch.object(agent, "_compress_context", mock_compress), + ): + result = agent.run_conversation("hello") + + second_call = agent.client.chat.completions.create.call_args_list[1].kwargs + assert result["completed"] is True + assert second_call["max_tokens"] <= 936 + assert agent.context_compressor.context_length == 200_000 + mock_compress.assert_not_called() + + def test_output_cap_retry_with_large_api_only_content(self, agent): + """When a large system prompt makes api_messages huge while persisted + messages stay tiny, the retry cap must still respect provider + available_tokens — not blow up to the full context window. + """ + self._setup_agent(agent) + agent.api_mode = "chat_completions" + agent.provider = "openrouter" + agent.model = "some/model" + agent.max_tokens = 65_536 + agent.compression_enabled = True + agent.context_compressor.context_length = 200_000 + agent.context_compressor.should_compress = MagicMock(return_value=False) + + # Huge API-only system prompt; persisted messages are tiny. + agent._cached_system_prompt = "S" * 796_000 + + error_msg = ( + "max_tokens: 65536 > context_window: 200000 " + "- input_tokens: 199000 = available_tokens: 1000" + ) + exc = Exception(error_msg) + exc.status_code = 400 + exc.code = 400 + + ok_resp = _mock_response(content="done", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [exc, ok_resp] + + mock_compress = MagicMock() + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent.context_compressor, "update_model"), + patch.object(agent, "_compress_context", mock_compress), + ): + result = agent.run_conversation("hello") + + second_call = agent.client.chat.completions.create.call_args_list[1].kwargs + assert result["completed"] is True + # The current branch (messages-only estimate) would send max_tokens + # near 199927 — this test fails on it. + assert second_call["max_tokens"] <= 936 + assert agent.context_compressor.context_length == 200_000 + mock_compress.assert_not_called() + + def test_output_cap_retry_request_pressure_lower_bound(self, agent): + """When the provider reports a large available_tokens but local request + pressure leaves less room, the retry cap is the smaller of the two. + """ + self._setup_agent(agent) + agent.api_mode = "chat_completions" + agent.provider = "openrouter" + agent.model = "some/model" + agent.max_tokens = 65_536 + agent.compression_enabled = True + agent.context_compressor.context_length = 200_000 + agent.context_compressor.should_compress = MagicMock(return_value=False) + + # A large API-only system prompt so the local estimate is the binding + # constraint, not the provider's available_tokens. + agent._cached_system_prompt = "S" * 796_000 + + error_msg = ( + "max_tokens: 65536 > context_window: 200000 " + "- input_tokens: 190000 = available_tokens: 50000" + ) + exc = Exception(error_msg) + exc.status_code = 400 + exc.code = 400 + + ok_resp = _mock_response(content="done", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [exc, ok_resp] + + mock_compress = MagicMock() + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent.context_compressor, "update_model"), + patch.object(agent, "_compress_context", mock_compress), + ): + result = agent.run_conversation("hello") + + first_call = agent.client.chat.completions.create.call_args_list[0].kwargs + second_call = agent.client.chat.completions.create.call_args_list[1].kwargs + assert result["completed"] is True + + # Verify the local estimate is actually the lower bound. + from agent.model_metadata import estimate_request_tokens_rough + estimated_request = estimate_request_tokens_rough( + first_call["messages"], tools=agent.tools or None, + ) + local_available = 200_000 - estimated_request + expected_cap = max(1, min(50_000, local_available) - 64) + assert local_available < 50_000 + assert second_call["max_tokens"] == expected_cap + assert agent.context_compressor.context_length == 200_000 + mock_compress.assert_not_called() + + def test_output_cap_retry_safety_floor_at_one(self, agent): + """When provider available_tokens is 1, the retry cap is floored at 1.""" + self._setup_agent(agent) + agent.api_mode = "chat_completions" + agent.provider = "openrouter" + agent.model = "some/model" + agent.max_tokens = 65_536 + agent.compression_enabled = True + agent.context_compressor.context_length = 200_000 + agent.context_compressor.should_compress = MagicMock(return_value=False) + + error_msg = ( + "max_tokens: 65536 > context_window: 200000 " + "- input_tokens: 199999 = available_tokens: 1" + ) + exc = Exception(error_msg) + exc.status_code = 400 + exc.code = 400 + + ok_resp = _mock_response(content="done", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [exc, ok_resp] + + mock_compress = MagicMock() + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent.context_compressor, "update_model"), + patch.object(agent, "_compress_context", mock_compress), + ): + result = agent.run_conversation("hello") + + second_call = agent.client.chat.completions.create.call_args_list[1].kwargs + assert result["completed"] is True + assert second_call["max_tokens"] == 1 + assert agent.context_compressor.context_length == 200_000 + mock_compress.assert_not_called() + + def test_output_cap_retry_with_compression_disabled(self, agent): + """Output-cap retry must still work when compression.enabled is false. + The recovery is a max_tokens-only retry — it does not require compression, + so the compression-disabled guard must not block it. + """ + self._setup_agent(agent) + agent.api_mode = "chat_completions" + agent.provider = "openrouter" + agent.model = "some/model" + agent.max_tokens = 65_536 + agent.compression_enabled = False + agent.context_compressor.context_length = 200_000 + agent.context_compressor.should_compress = MagicMock(return_value=False) + + error_msg = ( + "max_tokens: 65536 > context_window: 200000 " + "- input_tokens: 199000 = available_tokens: 1000" + ) + exc = Exception(error_msg) + exc.status_code = 400 + exc.code = 400 + + ok_resp = _mock_response(content="done", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [exc, ok_resp] + + mock_compress = MagicMock() + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent.context_compressor, "update_model"), + patch.object(agent, "_compress_context", mock_compress), + ): + result = agent.run_conversation("hello") + + # Two API calls: the failed one and the retried one. + assert len(agent.client.chat.completions.create.call_args_list) == 2 + second_call = agent.client.chat.completions.create.call_args_list[1].kwargs + assert result["completed"] is True + assert result.get("compaction_disabled") is None + assert second_call["max_tokens"] <= 936 + assert agent.context_compressor.context_length == 200_000 + mock_compress.assert_not_called() + + def test_output_cap_retry_with_compression_disabled_vllm_format(self, agent): + """vLLM/LM Studio error messages contain 'prompt contains ... input + tokens' which is_output_cap_error() treats as an input-overflow signal + (returns False). But parse_available_output_tokens_from_error() CAN + extract a valid available_tokens from them. The compression-disabled + guard must exempt these too — otherwise users on vLLM/LM Studio with + compression off get a terminal failure instead of a max-tokens retry. + """ + self._setup_agent(agent) + agent.api_mode = "chat_completions" + agent.provider = "openrouter" + agent.model = "some/model" + agent.max_tokens = 65_536 + agent.compression_enabled = False + agent.context_compressor.context_length = 131_072 + agent.context_compressor.should_compress = MagicMock(return_value=False) + + # vLLM-format error (from tests/test_output_cap_parsing.py) + error_msg = ( + "This model's maximum context length is 131072 tokens. " + "However, you requested 1024 output tokens and your prompt " + "contains at least 65537 input tokens, for a total of at least " + "66561 tokens." + ) + exc = Exception(error_msg) + exc.status_code = 400 + exc.code = 400 + + ok_resp = _mock_response(content="done", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [exc, ok_resp] + + mock_compress = MagicMock() + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent.context_compressor, "update_model"), + patch.object(agent, "_compress_context", mock_compress), + ): + result = agent.run_conversation("hello") + + assert len(agent.client.chat.completions.create.call_args_list) == 2 + second_call = agent.client.chat.completions.create.call_args_list[1].kwargs + assert result["completed"] is True + assert result.get("compaction_disabled") is None + # parse_available_output_tokens_from_error returns 65535 for this message + assert second_call["max_tokens"] <= 65471 # 65535 - 64 + assert agent.context_compressor.context_length == 131_072 + mock_compress.assert_not_called() + class TestHookPayloadSanitizesSimpleNamespace: """Regression: ``_hook_jsonable`` referenced ``SimpleNamespace`` without diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 0c24adc4ed6c..f307b0b44579 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -821,6 +821,81 @@ def test_run_conversation_codex_plain_text(monkeypatch): assert result["messages"][-1]["content"] == "OK" +def test_copilot_final_preflight_sanitizes_both_middleware_layers(monkeypatch): + """The dispatch chokepoint must sanitize after every mutable layer.""" + agent = _build_copilot_agent(monkeypatch) + setattr(agent, "_disable_streaming", True) + captured = {} + + def _message_item(item_id, *, text, phase, status): + return { + "type": "message", + "role": "assistant", + "status": status, + "content": [{"type": "output_text", "text": text}], + "id": item_id, + "phase": phase, + } + + def _request_middleware(request, **_context): + replacement = dict(request) + replacement["input"] = [ + _message_item( + "request_middleware_id", + text="request-layer", + phase="commentary", + status="completed", + ) + ] + return SimpleNamespace( + payload=replacement, + original_payload=request, + changed=True, + trace=[], + ) + + def _execution_middleware(request, next_call, **_context): + # Request middleware runs after the initial preflight, so its ID is + # still present here. The dispatch chokepoint must remove the ID that + # this execution middleware introduces immediately before the API call. + assert request["input"][0]["id"] == "request_middleware_id" + replacement = dict(request) + replacement["input"] = [ + _message_item( + "execution_middleware_id", + text="execution-layer", + phase="final_answer", + status="in_progress", + ) + ] + return next_call(replacement) + + def _capture_api_call(api_kwargs): + captured.update(api_kwargs) + return _codex_message_response("OK") + + monkeypatch.setattr( + "hermes_cli.middleware.apply_llm_request_middleware", + _request_middleware, + ) + monkeypatch.setattr( + "hermes_cli.middleware.run_llm_execution_middleware", + _execution_middleware, + ) + monkeypatch.setattr(agent, "_interruptible_api_call", _capture_api_call) + + result = agent.run_conversation("Say OK") + + assert result["completed"] is True + message_item = captured["input"][0] + assert "id" not in message_item + assert message_item["status"] == "in_progress" + assert message_item["phase"] == "final_answer" + assert message_item["content"] == [ + {"type": "output_text", "text": "execution-layer"} + ] + + def test_run_conversation_codex_empty_output_with_output_text(monkeypatch): """Regression: empty response.output + valid output_text should succeed, not trigger retry/fallback. The validation stage must defer to @@ -1629,6 +1704,90 @@ def test_mid_turn_compaction_does_not_double_persist_in_place_rows(monkeypatch, ) +def _codex_incomplete_with_reasoning(text: str, reasoning_id: str = "rs_default"): + """Incomplete response with a reasoning item whose id/encrypted_content + can vary independently of the visible message text.""" + return SimpleNamespace( + output=[ + SimpleNamespace( + type="reasoning", + id=reasoning_id, + encrypted_content=f"opaque_{reasoning_id}", + summary=[SimpleNamespace(text="thinking...")], + ), + SimpleNamespace( + type="message", + status="in_progress", + content=[SimpleNamespace(type="output_text", text=text)], + ), + ], + usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6), + status="in_progress", + model="gpt-5-codex", + ) + + +def test_codex_incomplete_visible_dedup_suppresses_duplicate_interims(monkeypatch): + """Two consecutive incomplete responses with identical visible content + but different opaque reasoning items should be collapsed — only the first + interim is emitted to the user (#52711).""" + agent = _build_agent(monkeypatch) + # 2 incompletes with same text but different reasoning ids, then a final. + # (Only 2 to avoid hitting the cap of 3.) + responses = [ + _codex_incomplete_with_reasoning("Working on it...", "rs_1"), + _codex_incomplete_with_reasoning("Working on it...", "rs_2"), + _codex_message_response("Done."), + ] + monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0)) + + emitted: list = [] + original_emit = agent._emit_interim_assistant_message + def _capture_emit(msg): + emitted.append(msg.get("content")) + original_emit(msg) + monkeypatch.setattr(agent, "_emit_interim_assistant_message", _capture_emit) + + result = agent.run_conversation("test dedup") + + assert result["completed"] is True + # Only ONE interim should have been emitted (the first), not two. + assert len(emitted) == 1 + assert emitted[0] == "Working on it..." + + +def test_codex_incomplete_opaque_state_updated_in_place(monkeypatch): + """When visible content is a duplicate, the last message's opaque state + (codex_reasoning_items) should be updated in-place without emitting a new + interim (#52711).""" + agent = _build_agent(monkeypatch) + responses = [ + _codex_incomplete_with_reasoning("Partial output...", "rs_1"), + _codex_incomplete_with_reasoning("Partial output...", "rs_2"), + _codex_message_response("Final."), + ] + monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0)) + + result = agent.run_conversation("test opaque update") + + assert result["completed"] is True + # Find the incomplete interim message in the result. + incompletes = [ + m for m in result["messages"] + if m.get("role") == "assistant" and m.get("finish_reason") == "incomplete" + ] + # Only one incomplete message should exist (the second was deduped). + assert len(incompletes) == 1 + # The opaque state should reflect the LATEST reasoning item (rs_2), + # updated in-place on the single message. + items = incompletes[0].get("codex_reasoning_items") + if items: + assert any( + (i.get("id") if isinstance(i, dict) else getattr(i, "id", None)) == "rs_2" + for i in items + ) + + def test_normalize_codex_response_marks_commentary_only_message_as_incomplete(monkeypatch): agent = _build_agent(monkeypatch) from agent.codex_responses_adapter import _normalize_codex_response @@ -2284,15 +2443,16 @@ def _codex_reasoning_only_response(*, encrypted_content="enc_abc123", summary_te def test_normalize_codex_response_marks_reasoning_only_as_incomplete(monkeypatch): - """A response with only reasoning items and no content should be 'incomplete', not 'stop'. + """A response with only reasoning items and no content should be 'incomplete' for Codex backends. - Without this fix, reasoning-only responses get finish_reason='stop' which - sends them into the empty-content retry loop (3 retries then failure). + Codex CLI uses reasoning-only responses as a signal that the model is still + thinking and needs another turn. This test verifies the Codex-specific path + where issuer_kind="codex_backend" preserves the old behavior. """ agent = _build_agent(monkeypatch) from agent.codex_responses_adapter import _normalize_codex_response assistant_message, finish_reason = _normalize_codex_response( - _codex_reasoning_only_response() + _codex_reasoning_only_response(), issuer_kind="codex_backend" ) assert finish_reason == "incomplete" @@ -2302,6 +2462,74 @@ def test_normalize_codex_response_marks_reasoning_only_as_incomplete(monkeypatch assert assistant_message.codex_reasoning_items[0]["encrypted_content"] == "enc_abc123" +def test_normalize_codex_response_reasoning_only_completed_is_stop_for_other_backends(monkeypatch): + """Reasoning-only with status='completed' should be 'stop' for non-Codex backends. + + When response.status == "completed" and no items are queued/in_progress, + reasoning alone is a valid final state for non-Codex backends. Forcing + "incomplete" here causes multi-minute stalls (3 retries x up to 240s each). + See https://github.com/NousResearch/hermes-agent/issues/64434 + """ + agent = _build_agent(monkeypatch) + from agent.codex_responses_adapter import _normalize_codex_response + response = _codex_reasoning_only_response() + assistant_message, finish_reason = _normalize_codex_response( + response, issuer_kind="other:example-relay" + ) + + assert finish_reason == "stop" + assert assistant_message.content == "" + assert assistant_message.codex_reasoning_items is not None + assert len(assistant_message.codex_reasoning_items) == 1 + + +def test_normalize_codex_response_reasoning_only_completed_is_stop_without_issuer(monkeypatch): + """Default issuer (None) should also trust response.status='completed' for reasoning-only. + + When no issuer_kind is provided (test or default scenario) and the provider + says status='completed', reasoning-only should be treated as 'stop'. + """ + agent = _build_agent(monkeypatch) + from agent.codex_responses_adapter import _normalize_codex_response + response = _codex_reasoning_only_response() + assistant_message, finish_reason = _normalize_codex_response(response) + + assert finish_reason == "stop" + assert assistant_message.content == "" + + +def test_normalize_codex_response_reasoning_only_stays_incomplete_for_xai_backend(monkeypatch): + """xAI backend also preserves incomplete for reasoning-only (same as Codex).""" + agent = _build_agent(monkeypatch) + from agent.codex_responses_adapter import _normalize_codex_response + response = _codex_reasoning_only_response() + assistant_message, finish_reason = _normalize_codex_response( + response, issuer_kind="xai_responses" + ) + + assert finish_reason == "incomplete" + assert assistant_message.content == "" + + +def test_normalize_codex_response_reasoning_only_stays_incomplete_for_github_backend(monkeypatch): + """GitHub/Copilot Responses backend preserves incomplete for reasoning-only. + + Copilot fronts the same OpenAI model family as codex_backend and exhibits + the same reasoning-only "still thinking" degeneration mode, so it must + stay on the continuation path — only unrecognized (other:*) backends + trust response.status='completed' as terminal. + """ + agent = _build_agent(monkeypatch) + from agent.codex_responses_adapter import _normalize_codex_response + response = _codex_reasoning_only_response() + assistant_message, finish_reason = _normalize_codex_response( + response, issuer_kind="github_responses" + ) + + assert finish_reason == "incomplete" + assert assistant_message.content == "" + + def test_normalize_codex_response_reasoning_with_content_is_stop(monkeypatch): """If a response has both reasoning and message content, it should still be 'stop'.""" agent = _build_agent(monkeypatch) @@ -2464,7 +2692,8 @@ def test_codex_message_item_status_survives_conversion_and_preflight(monkeypatch def test_duplicate_detection_distinguishes_different_codex_reasoning(monkeypatch): """Two consecutive reasoning-only responses with different encrypted content - must NOT be treated as duplicates.""" + are deduped on visible content — only one interim is kept, but opaque state + is updated in-place (#52711).""" agent = _build_agent(monkeypatch) responses = [ # First reasoning-only response @@ -2497,23 +2726,23 @@ def test_duplicate_detection_distinguishes_different_codex_reasoning(monkeypatch assert result["completed"] is True assert result["final_response"] == "Final answer after thinking." - # Both reasoning-only interim messages should be in history (not collapsed) + # Only one reasoning-only interim should be in history (deduped on + # visible content — both have empty visible output). interim_msgs = [ msg for msg in result["messages"] if msg.get("role") == "assistant" and msg.get("finish_reason") == "incomplete" ] - assert len(interim_msgs) == 2 - encrypted_contents = [ - msg["codex_reasoning_items"][0]["encrypted_content"] - for msg in interim_msgs - ] - assert "enc_first" in encrypted_contents - assert "enc_second" in encrypted_contents + assert len(interim_msgs) == 1 + # But the opaque state should reflect the LATEST reasoning item. + items = interim_msgs[0].get("codex_reasoning_items") + if items: + assert items[0].get("encrypted_content") == "enc_second" def test_duplicate_detection_distinguishes_different_codex_message_items(monkeypatch): - """Incomplete turns with new message ids/phases/statuses must not be collapsed.""" + """Incomplete turns with same visible content but different message ids + are deduped — only one interim kept, opaque state updated in-place (#52711).""" agent = _build_agent(monkeypatch) responses = [ SimpleNamespace( @@ -2556,12 +2785,12 @@ def test_duplicate_detection_distinguishes_different_codex_message_items(monkeyp if msg.get("role") == "assistant" and msg.get("finish_reason") == "incomplete" ] - assert len(interim_msgs) == 2 - assert [msg["codex_message_items"][0]["id"] for msg in interim_msgs] == [ - "msg_first", - "msg_second", - ] - assert all(msg["codex_message_items"][0]["status"] == "in_progress" for msg in interim_msgs) + # Only one interim — deduped on visible content ("Still working..." == "Still working..."). + assert len(interim_msgs) == 1 + # Opaque state should reflect the latest message item. + items = interim_msgs[0].get("codex_message_items") + if items: + assert items[0].get("id") == "msg_second" def test_chat_messages_to_responses_input_deduplicates_reasoning_ids(monkeypatch): @@ -2731,3 +2960,72 @@ def test_run_conversation_codex_invalid_encrypted_content_without_replay_state_d assert all(not any(item.get("type") == "reasoning" for item in payload["input"]) for payload in request_payloads) assert agent._codex_reasoning_replay_enabled is True assert result["messages"][0].get("codex_reasoning_items") is None + + +def test_run_conversation_codex_nudges_after_unreplayable_reasoning_only_interim(monkeypatch): + """A reasoning-only interim with NO encrypted_content (the shape + grok-4.20 on xai-oauth returns when it never emits a message output + item) replays as nothing — without a nudge every continuation request + is byte-identical to the one that just came back incomplete.""" + agent = _build_agent(monkeypatch) + requests = [] + responses = [ + _codex_reasoning_only_response( + encrypted_content=None, + summary_text="Thinking about the repo structure...", + ), + _codex_message_response("Final answer."), + ] + + def _fake_api_call(api_kwargs): + requests.append(api_kwargs) + return responses.pop(0) + + monkeypatch.setattr(agent, "_interruptible_api_call", _fake_api_call) + + result = agent.run_conversation("analyze repo") + + assert result["completed"] is True + assert result["final_response"] == "Final answer." + assert len(requests) == 2 + + replay_input = requests[1]["input"] + nudges = [ + item for item in replay_input + if isinstance(item, dict) + and item.get("role") == "user" + and "only internal reasoning" in str(item.get("content")) + ] + assert len(nudges) == 1, ( + "Continuation after an unreplayable reasoning-only interim must " + "append the nudge user message; otherwise the retry request is " + "identical to the one that just failed." + ) + + +def test_run_conversation_codex_no_nudge_for_replayable_interim(monkeypatch): + """An interim that carries visible content replays fine — the nudge + must not fire and pollute the conversation.""" + agent = _build_agent(monkeypatch) + requests = [] + responses = [ + _codex_incomplete_message_response("Partial visible content."), + _codex_message_response("Done."), + ] + + def _fake_api_call(api_kwargs): + requests.append(api_kwargs) + return responses.pop(0) + + monkeypatch.setattr(agent, "_interruptible_api_call", _fake_api_call) + + result = agent.run_conversation("analyze repo") + + assert result["completed"] is True + replay_input = requests[1]["input"] + assert not any( + isinstance(item, dict) + and item.get("role") == "user" + and "only internal reasoning" in str(item.get("content")) + for item in replay_input + ) diff --git a/tests/run_agent/test_streaming.py b/tests/run_agent/test_streaming.py index 7ccafff665a4..cdbcd51828bd 100644 --- a/tests/run_agent/test_streaming.py +++ b/tests/run_agent/test_streaming.py @@ -771,6 +771,34 @@ class TestStreamingFallback: assert mock_client.chat.completions.create.call_count == 3 assert mock_close.call_count >= 1 + @patch("run_agent.AIAgent._create_request_openai_client") + @patch("run_agent.AIAgent._close_request_openai_client") + def test_zero_chunk_stream_retried_as_transient(self, mock_close, mock_create): + """A stream that yields no chunks gets the same retry budget as a drop.""" + from agent.errors import EmptyStreamError + from run_agent import AIAgent + + mock_client = MagicMock() + mock_client.chat.completions.create.return_value = iter(()) + mock_create.return_value = mock_client + + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + model="test/model", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.api_mode = "chat_completions" + agent._interrupt_requested = False + + with pytest.raises(EmptyStreamError): + agent._interruptible_streaming_api_call({}) + + assert mock_client.chat.completions.create.call_count == 3 + assert mock_close.call_count >= 1 + @patch("run_agent.AIAgent._create_request_openai_client") @patch("run_agent.AIAgent._close_request_openai_client") def test_sse_connection_lost_retried_as_transient(self, mock_close, mock_create): @@ -1265,6 +1293,90 @@ class TestAnthropicStreamCallbacks: assert agent._anthropic_client.messages.stream.call_count == 1 assert mock_replace.call_count == 0 + @patch("run_agent.AIAgent._try_refresh_anthropic_client_credentials") + @patch("run_agent.AIAgent._rebuild_anthropic_client") + @patch("run_agent.AIAgent._replace_primary_openai_client") + def test_anthropic_zero_event_stream_retried_as_transient( + self, mock_replace, mock_rebuild, mock_refresh, + ): + """An eventless Anthropic stream with an empty final Message gets the + same transient retry budget as the chat_completions zero-chunk guard + (parity follow-up to #64420).""" + from agent.errors import EmptyStreamError + from run_agent import AIAgent + + agent = AIAgent( + api_key="test-key", + base_url="https://api.anthropic.com", + provider="anthropic", + model="claude-test", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.api_mode = "anthropic_messages" + agent._interrupt_requested = False + + empty_message = SimpleNamespace(content=[], stop_reason=None) + empty_stream = MagicMock() + empty_stream.__enter__ = MagicMock(return_value=empty_stream) + empty_stream.__exit__ = MagicMock(return_value=False) + empty_stream.__iter__ = MagicMock(side_effect=lambda: iter([])) + empty_stream.get_final_message.return_value = empty_message + + agent._anthropic_client = MagicMock() + agent._anthropic_client.messages.stream.return_value = empty_stream + + with pytest.raises(EmptyStreamError): + agent._interruptible_streaming_api_call({}) + + assert agent._anthropic_client.messages.stream.call_count == 3 + # Anthropic-native cleanup between attempts: rebuild the Anthropic + # client, never the OpenAI primary client. + assert mock_replace.call_count == 0 + assert mock_rebuild.call_count == 2 + + @patch("run_agent.AIAgent._try_refresh_anthropic_client_credentials") + @patch("run_agent.AIAgent._rebuild_anthropic_client") + @patch("run_agent.AIAgent._replace_primary_openai_client") + def test_anthropic_eventless_sdk_assertion_normalized_to_empty_stream( + self, mock_replace, mock_rebuild, mock_refresh, + ): + """Real-SDK shape: an eventless stream has no message_start, so + get_final_message() raises AssertionError (final snapshot is None). + That must be normalized to EmptyStreamError and retried as + transient — not surface as a raw AssertionError.""" + from agent.errors import EmptyStreamError + from run_agent import AIAgent + + agent = AIAgent( + api_key="test-key", + base_url="https://api.anthropic.com", + provider="anthropic", + model="claude-test", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.api_mode = "anthropic_messages" + agent._interrupt_requested = False + + empty_stream = MagicMock() + empty_stream.__enter__ = MagicMock(return_value=empty_stream) + empty_stream.__exit__ = MagicMock(return_value=False) + empty_stream.__iter__ = MagicMock(side_effect=lambda: iter([])) + empty_stream.get_final_message.side_effect = AssertionError() + + agent._anthropic_client = MagicMock() + agent._anthropic_client.messages.stream.return_value = empty_stream + + with pytest.raises(EmptyStreamError): + agent._interruptible_streaming_api_call({}) + + assert agent._anthropic_client.messages.stream.call_count == 3 + assert mock_replace.call_count == 0 + assert mock_rebuild.call_count == 2 + class TestPartialToolCallWarning: """Regression: when a stream dies mid tool-call argument generation after diff --git a/tests/run_agent/test_summarize_api_error.py b/tests/run_agent/test_summarize_api_error.py index 6739d8e48e8a..f16cc76b1076 100644 --- a/tests/run_agent/test_summarize_api_error.py +++ b/tests/run_agent/test_summarize_api_error.py @@ -11,6 +11,9 @@ provider error. This is a diagnostic improvement and is platform-agnostic. """ from types import SimpleNamespace +from typing import Any + +import httpx from run_agent import AIAgent @@ -54,3 +57,25 @@ def test_empty_body_fallback_redacts_secrets(monkeypatch): summary = AIAgent._summarize_api_error(err) assert "sk-proj-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef" not in summary + +def test_unread_streaming_response_does_not_crash_and_falls_back_to_exception_message(): + """Unread streaming responses must not replace the real provider error.""" + + class _StreamingError(Exception): + def __init__(self): + super().__init__("Gemini HTTP 429: quota exceeded") + self.status_code = 429 + self.response: Any = None + + err = _StreamingError() + + class _UnreadStreamingResponse: + @property + def text(self): + raise httpx.ResponseNotRead() + + err.response = _UnreadStreamingResponse() + summary = AIAgent._summarize_api_error(err) + assert "HTTP 429" in summary + assert "Gemini HTTP 429: quota exceeded" in summary + diff --git a/tests/run_agent/test_switch_model_pool_reload_52727.py b/tests/run_agent/test_switch_model_pool_reload_52727.py index a1dba807f9f8..e1ef7d651c0a 100644 --- a/tests/run_agent/test_switch_model_pool_reload_52727.py +++ b/tests/run_agent/test_switch_model_pool_reload_52727.py @@ -213,6 +213,7 @@ class TestSwitchModelReloadsCredentialPool: ) # The switch itself completed (provider/model updated) even though - # the pool reload failed. + # the pool reload failed, without retaining the old provider's pool. assert agent.provider == "groq" - assert agent.model == "llama-3.3-70b" \ No newline at end of file + assert agent.model == "llama-3.3-70b" + assert agent._credential_pool is None diff --git a/tests/run_agent/test_switch_model_reapplies_headers.py b/tests/run_agent/test_switch_model_reapplies_headers.py new file mode 100644 index 000000000000..cd24bb622bad --- /dev/null +++ b/tests/run_agent/test_switch_model_reapplies_headers.py @@ -0,0 +1,100 @@ +"""Regression tests for #61099: switch_model must reapply provider-specific +default headers when it rebuilds _client_kwargs from scratch. + +Without _apply_client_headers_for_base_url() in the rebuild path, a /model +switch drops OpenRouter attribution headers (HTTP-Referer / X-Title → logs +show "Unknown") and, worse, functional headers like Kimi's User-Agent +sentinel (403 without it). +""" + +from unittest.mock import MagicMock, patch + +from run_agent import AIAgent +from agent.context_compressor import ContextCompressor + + +def _make_agent(provider="copilot", base_url="https://api.githubcopilot.com") -> AIAgent: + """Minimal AIAgent with a context_compressor, skipping __init__.""" + agent = AIAgent.__new__(AIAgent) + + agent.model = "claude-opus-4.8" + agent.provider = provider + agent.base_url = base_url + agent.api_key = "sk-primary" + agent.api_mode = "chat_completions" + agent.client = MagicMock() + agent.quiet_mode = True + agent._config_context_length = None + agent._client_kwargs = {"api_key": "sk-primary", "base_url": base_url} + + compressor = ContextCompressor( + model=agent.model, + threshold_percent=0.50, + base_url=base_url, + api_key="sk-primary", + provider=provider, + quiet_mode=True, + config_context_length=None, + ) + agent.context_compressor = compressor + agent._primary_runtime = {} + + return agent + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_to_openrouter_reapplies_attribution_headers(mock_ctx_len): + """Switching to an openrouter.ai base_url must attach the OpenRouter + attribution headers (HTTP-Referer / X-Title) to the rebuilt client + kwargs — not ship a bare api_key+base_url client (#61099).""" + agent = _make_agent(provider="copilot", base_url="https://api.githubcopilot.com") + + agent.switch_model( + "deepseek/deepseek-chat", + "openrouter", + api_key="sk-or-new", + base_url="https://openrouter.ai/api/v1", + ) + + headers = agent._client_kwargs.get("default_headers") or {} + assert "HTTP-Referer" in headers + assert headers.get("X-Title") + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_to_kimi_reapplies_user_agent_sentinel(mock_ctx_len): + """Kimi requires a User-Agent sentinel; a switch to api.kimi.com must + carry it or every request 403s.""" + agent = _make_agent(provider="openrouter", base_url="https://openrouter.ai/api/v1") + + agent.switch_model( + "kimi-k2", + "kimi", + api_key="sk-kimi", + base_url="https://api.kimi.com/v1", + ) + + headers = agent._client_kwargs.get("default_headers") or {} + assert headers.get("User-Agent", "").startswith("claude-code/") + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_away_from_headered_provider_clears_stale_headers(mock_ctx_len): + """Switching FROM a headered provider TO one with no URL-specific headers + must not carry the old provider's headers along.""" + agent = _make_agent(provider="openrouter", base_url="https://openrouter.ai/api/v1") + agent._client_kwargs["default_headers"] = { + "HTTP-Referer": "https://hermes-agent.nousresearch.com", + "X-Title": "Hermes Agent", + } + + agent.switch_model( + "MiniMax-M3", + "custom:minimax", + api_key="sk-minimax", + base_url="https://api.minimax.io/v1", + ) + + headers = agent._client_kwargs.get("default_headers") or {} + assert "HTTP-Referer" not in headers + assert "X-Title" not in headers diff --git a/tests/run_agent/test_switch_model_reasoning_override.py b/tests/run_agent/test_switch_model_reasoning_override.py new file mode 100644 index 000000000000..ae304e118df3 --- /dev/null +++ b/tests/run_agent/test_switch_model_reasoning_override.py @@ -0,0 +1,220 @@ +"""Tests for per-model reasoning_effort override during /model switch. + +Tests that switch_model: +1. Re-resolves reasoning_config when switching to a model with an override +2. Falls back to global when switching to a model without an override +3. Saves reasoning_config into _primary_runtime for fallback recovery +""" + +import pytest +from unittest.mock import MagicMock, patch + + +class TestSwitchModelReasoningOverride: + """Test switch_model re-resolves reasoning_config on model switch.""" + + def _make_fake_agent(self, model="gpt-5", provider="openai"): + """Create a minimal fake agent for switch_model testing.""" + agent = MagicMock() + agent.model = model + agent.provider = provider + agent.base_url = "https://api.openai.com/v1" + agent.api_mode = "openai" + agent.api_key = "test-key" + agent._client_kwargs = {"api_key": "test-key", "base_url": "https://api.openai.com/v1"} + agent._use_prompt_caching = False + agent._use_native_cache_layout = False + agent.reasoning_config = {"enabled": True, "effort": "medium"} + agent._fallback_activated = False + agent._fallback_index = 0 + agent._fallback_chain = [] + agent._fallback_model = None + agent._config_context_length = None + agent._transport_cache = {} + agent.context_compressor = None + agent._cached_system_prompt = None + agent._anthropic_api_key = "" + agent._anthropic_base_url = None + agent._is_anthropic_oauth = False + agent._anthropic_prompt_cache_policy = MagicMock( + return_value=(False, False) + ) + agent._ensure_lmstudio_runtime_loaded = MagicMock() + agent._create_openai_client = MagicMock(return_value=MagicMock()) + return agent + + def test_primary_runtime_includes_reasoning_config(self): + """After switch_model, _primary_runtime should contain reasoning_config key.""" + from agent.agent_runtime_helpers import switch_model + + agent = self._make_fake_agent() + + fake_cfg = { + "model": {"default": "claude-opus-4.5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": { + "claude-opus-4.5": "xhigh", + }, + }, + } + + with patch("hermes_cli.config.load_config", return_value=fake_cfg): + try: + switch_model( + agent, + new_model="claude-opus-4.5", + new_provider="anthropic", + base_url="https://api.anthropic.com", + api_mode="anthropic_messages", + ) + except Exception: + # Client creation may fail in test env; check _primary_runtime was set + pass + + assert hasattr(agent, "_primary_runtime") + assert "reasoning_config" in agent._primary_runtime + + def test_reasoning_config_resolves_to_override_on_switch(self): + """switch_model should resolve reasoning_config to per-model override.""" + from agent.agent_runtime_helpers import switch_model + + agent = self._make_fake_agent() + + fake_cfg = { + "model": {"default": "claude-opus-4.5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": { + "claude-opus-4.5": "xhigh", + }, + }, + } + + with patch("hermes_cli.config.load_config", return_value=fake_cfg): + try: + switch_model( + agent, + new_model="claude-opus-4.5", + new_provider="anthropic", + base_url="https://api.anthropic.com", + api_mode="anthropic_messages", + ) + except Exception: + pass + + # reasoning_config should be updated to xhigh + assert agent.reasoning_config is not None + assert agent.reasoning_config.get("effort") == "xhigh" + + def test_reasoning_config_falls_back_to_global(self): + """switch_model should fall back to global when no override for new model.""" + from agent.agent_runtime_helpers import switch_model + + agent = self._make_fake_agent() + + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": "low", + "reasoning_overrides": { + "claude-opus-4.5": "xhigh", # override for different model + }, + }, + } + + with patch("hermes_cli.config.load_config", return_value=fake_cfg): + try: + switch_model( + agent, + new_model="gpt-5", + new_provider="openai", + api_mode="openai", + ) + except Exception: + pass + + # No override for gpt-5 → should fall back to global "low" + assert agent.reasoning_config is not None + assert agent.reasoning_config.get("effort") == "low" + + def test_restore_primary_runtime_restores_reasoning(self): + """restore_primary_runtime should restore reasoning_config from snapshot.""" + from agent.agent_runtime_helpers import restore_primary_runtime + + agent = MagicMock() + agent._primary_runtime = { + "model": "claude-opus-4.5", + "provider": "anthropic", + "base_url": "https://api.anthropic.com", + "api_mode": "anthropic_messages", + "api_key": "key", + "client_kwargs": {}, + "use_prompt_caching": True, + "use_native_cache_layout": False, + "reasoning_config": {"enabled": True, "effort": "xhigh"}, + "compressor_model": "claude-opus-4.5", + "compressor_base_url": "", + "compressor_api_key": "", + "compressor_provider": "", + "compressor_context_length": 0, + "compressor_api_mode": "", + "compressor_threshold_tokens": 0, + "anthropic_api_key": "key", + "anthropic_base_url": "https://api.anthropic.com", + "is_anthropic_oauth": False, + } + agent._fallback_activated = True + agent._fallback_index = 0 + agent._fallback_chain = [] + agent._fallback_model = None + agent._transport_cache = {} + agent._config_context_length = None + agent._rate_limited_until = 0 + agent.model = "fallback-model" + agent.provider = "openai" + agent.reasoning_config = {"enabled": True, "effort": "medium"} + agent.context_compressor = MagicMock() + agent.base_url = "" + # Mock the methods restore_primary_runtime calls + agent._anthropic_prompt_cache_policy = MagicMock(return_value=(True, False)) + agent._create_openai_client = MagicMock(return_value=MagicMock()) + agent._ensure_lmstudio_runtime_loaded = MagicMock() + + result = restore_primary_runtime(agent) + assert result is True + assert agent.reasoning_config == {"enabled": True, "effort": "xhigh"} + + def test_switch_model_global_fallback_with_yaml_false(self): + """switch_model global fallback must not coerce YAML boolean False. + + Regression: str(... or "").strip() turned False into "", silently + re-enabling thinking. The raw value must pass through so + parse_reasoning_effort(False) returns {'enabled': False}. + """ + from agent.agent_runtime_helpers import switch_model + + agent = self._make_fake_agent() + + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": False, # YAML boolean, not string + "reasoning_overrides": {}, + }, + } + + with patch("hermes_cli.config.load_config", return_value=fake_cfg): + try: + switch_model( + agent, + new_model="gpt-5", + new_provider="openai", + api_mode="openai", + ) + except Exception: + pass + + # No override for gpt-5 → global fallback with raw False + assert agent.reasoning_config is not None + assert agent.reasoning_config.get("enabled") is False \ No newline at end of file diff --git a/tests/run_agent/test_switch_model_stale_base_url.py b/tests/run_agent/test_switch_model_stale_base_url.py new file mode 100644 index 000000000000..bd38eb6c4981 --- /dev/null +++ b/tests/run_agent/test_switch_model_stale_base_url.py @@ -0,0 +1,87 @@ +"""Regression tests for #47828: switch_model must not pair a new provider +label with the previous provider's base_url when the resolver returns no +new base_url for a genuine provider change. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent +from agent.context_compressor import ContextCompressor + + +def _make_agent_with_compressor(provider="copilot", base_url="https://api.githubcopilot.com") -> AIAgent: + """Build a minimal AIAgent with a context_compressor, skipping __init__.""" + agent = AIAgent.__new__(AIAgent) + + agent.model = "claude-opus-4.8" + agent.provider = provider + agent.base_url = base_url + agent.api_key = "sk-primary" + agent.api_mode = "chat_completions" + agent.client = MagicMock() + agent.quiet_mode = True + agent._config_context_length = None + + compressor = ContextCompressor( + model=agent.model, + threshold_percent=0.50, + base_url=base_url, + api_key="sk-primary", + provider=provider, + quiet_mode=True, + config_context_length=None, + ) + agent.context_compressor = compressor + agent._primary_runtime = {} + + return agent + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_model_rejects_stale_base_url_on_provider_change(mock_ctx_len): + """A provider change with no resolved base_url must fail loud instead of + silently keeping the previous provider's endpoint (#47828).""" + agent = _make_agent_with_compressor(provider="copilot", base_url="https://api.githubcopilot.com") + + with pytest.raises(ValueError, match="no base_url resolved"): + agent.switch_model("MiniMax-M3", "custom:minimax", api_key="sk-minimax", base_url="") + + # Rollback must leave the agent fully on the old (provider, base_url) pair — + # not a mismatched new-model/old-endpoint hybrid. + assert agent.provider == "copilot" + assert agent.base_url == "https://api.githubcopilot.com" + assert agent.model == "claude-opus-4.8" + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_model_allows_empty_base_url_for_same_provider(mock_ctx_len): + """Re-selecting the SAME provider (e.g. a credential-only refresh) with no + new base_url must keep the current URL — this is not a provider change.""" + agent = _make_agent_with_compressor(provider="openrouter", base_url="https://openrouter.ai/api/v1") + + agent.switch_model("new-model", "openrouter", api_key="sk-new", base_url="") + + assert agent.provider == "openrouter" + assert agent.base_url == "https://openrouter.ai/api/v1" + assert agent.model == "new-model" + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_model_applies_new_base_url_on_provider_change(mock_ctx_len): + """The normal, resolved-correctly path must still work: new provider + + new base_url is applied as-is.""" + agent = _make_agent_with_compressor(provider="copilot", base_url="https://api.githubcopilot.com") + + agent.switch_model( + "MiniMax-M3", "custom:minimax", api_key="sk-minimax", base_url="https://api.minimax.io/v1" + ) + + assert agent.provider == "custom:minimax" + assert agent.base_url == "https://api.minimax.io/v1" + assert agent.model == "MiniMax-M3" + # _primary_runtime must snapshot the coherent pair so it survives every + # subsequent restore_primary_runtime() call across turns. + assert agent._primary_runtime["provider"] == "custom:minimax" + assert agent._primary_runtime["base_url"] == "https://api.minimax.io/v1" diff --git a/tests/run_agent/test_tool_batch_segmentation.py b/tests/run_agent/test_tool_batch_segmentation.py new file mode 100644 index 000000000000..9cde31479d00 --- /dev/null +++ b/tests/run_agent/test_tool_batch_segmentation.py @@ -0,0 +1,400 @@ +"""Segment-aware mixed tool-batch dispatch. + +A model response containing several parallel-safe reads plus one unsafe +tool used to lose ALL concurrency: `_should_parallelize_tool_batch` was +all-or-nothing, so one barrier call forced the entire batch onto the +sequential path. `_plan_tool_batch_segments` now splits the batch into +ordered segments — maximal contiguous runs of parallel-safe calls execute +concurrently, barrier calls sequentially — while preserving: + + * model tool-result ordering (one result per call, in emission order), + * side-effect boundaries (no call starts before an earlier barrier ends). +""" + +import json +import threading +import time +import uuid +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent +from agent.tool_dispatch_helpers import ( + _plan_tool_batch_segments, + _should_parallelize_tool_batch, +) + + +def _tc(name="web_search", arguments="{}", call_id=None): + return SimpleNamespace( + id=call_id or f"call_{uuid.uuid4().hex[:8]}", + type="function", + function=SimpleNamespace(name=name, arguments=arguments), + ) + + +def _kinds(segments): + return [kind for kind, _ in segments] + + +def _flatten_ids(segments): + return [tc.id for _, calls in segments for tc in calls] + + +# --------------------------------------------------------------------------- +# Planner unit tests +# --------------------------------------------------------------------------- + + +class TestPlanToolBatchSegments: + def test_all_safe_batch_is_single_parallel_segment(self): + calls = [_tc("web_search"), _tc("read_file", '{"path":"a.py"}'), _tc("web_extract")] + segments = _plan_tool_batch_segments(calls) + assert _kinds(segments) == ["parallel"] + assert _flatten_ids(segments) == [c.id for c in calls] + + def test_three_safe_reads_plus_trailing_unsafe_keeps_reads_parallel(self): + """The headline case: 3 safe reads + 1 unsafe tool must NOT go fully sequential.""" + calls = [ + _tc("web_search", call_id="r1"), + _tc("web_search", call_id="r2"), + _tc("read_file", '{"path":"a.py"}', call_id="r3"), + _tc("terminal", '{"command":"echo hi"}', call_id="b1"), + ] + segments = _plan_tool_batch_segments(calls) + assert _kinds(segments) == ["parallel", "sequential"] + assert [tc.id for tc in segments[0][1]] == ["r1", "r2", "r3"] + assert [tc.id for tc in segments[1][1]] == ["b1"] + + def test_barrier_in_middle_splits_runs_and_preserves_order(self): + calls = [ + _tc("web_search", call_id="r1"), + _tc("web_search", call_id="r2"), + _tc("terminal", '{"command":"make"}', call_id="b1"), + _tc("web_search", call_id="r3"), + _tc("web_search", call_id="r4"), + ] + segments = _plan_tool_batch_segments(calls) + assert _kinds(segments) == ["parallel", "sequential", "parallel"] + assert _flatten_ids(segments) == ["r1", "r2", "b1", "r3", "r4"] + + def test_single_safe_call_after_barrier_is_demoted_and_merged(self): + # parallel run of 1 gains nothing — demote to sequential and merge + # with the adjacent barrier segment. + calls = [ + _tc("web_search", call_id="r1"), + _tc("web_search", call_id="r2"), + _tc("terminal", '{"command":"make"}', call_id="b1"), + _tc("web_search", call_id="r3"), + ] + segments = _plan_tool_batch_segments(calls) + assert _kinds(segments) == ["parallel", "sequential"] + assert [tc.id for tc in segments[1][1]] == ["b1", "r3"] + + def test_adjacent_barriers_merge_into_one_sequential_segment(self): + calls = [ + _tc("terminal", '{"command":"a"}', call_id="b1"), + _tc("terminal", '{"command":"b"}', call_id="b2"), + _tc("web_search", call_id="r1"), + _tc("web_search", call_id="r2"), + ] + segments = _plan_tool_batch_segments(calls) + assert _kinds(segments) == ["sequential", "parallel"] + assert [tc.id for tc in segments[0][1]] == ["b1", "b2"] + + def test_never_parallel_tool_is_a_barrier(self): + calls = [ + _tc("web_search", call_id="r1"), + _tc("web_search", call_id="r2"), + _tc("clarify", '{"question":"?"}', call_id="c1"), + ] + segments = _plan_tool_batch_segments(calls) + assert _kinds(segments) == ["parallel", "sequential"] + assert [tc.id for tc in segments[1][1]] == ["c1"] + + def test_malformed_args_call_is_a_barrier_not_a_batch_poison(self): + calls = [ + _tc("web_search", call_id="r1"), + _tc("web_search", call_id="r2"), + _tc("web_search", "{not json", call_id="bad"), + _tc("web_search", call_id="r3"), + _tc("web_search", call_id="r4"), + ] + segments = _plan_tool_batch_segments(calls) + assert _kinds(segments) == ["parallel", "sequential", "parallel"] + assert [tc.id for tc in segments[1][1]] == ["bad"] + + def test_non_dict_args_call_is_a_barrier(self): + calls = [ + _tc("web_search", call_id="r1"), + _tc("web_search", call_id="r2"), + _tc("web_search", '"just a string"', call_id="bad"), + ] + segments = _plan_tool_batch_segments(calls) + assert _kinds(segments) == ["parallel", "sequential"] + + def test_overlapping_paths_split_across_segments(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + calls = [ + _tc("read_file", '{"path":"a.py"}', call_id="w1"), + _tc("web_search", call_id="r1"), + _tc("write_file", '{"path":"a.py","content":"x"}', call_id="w2"), + _tc("web_search", call_id="r2"), + ] + segments = _plan_tool_batch_segments(calls) + # w2 conflicts with w1 → closes the first run; w2+r2 form the second. + assert _kinds(segments) == ["parallel", "parallel"] + assert [tc.id for tc in segments[0][1]] == ["w1", "r1"] + assert [tc.id for tc in segments[1][1]] == ["w2", "r2"] + # Order and completeness preserved. + assert _flatten_ids(segments) == ["w1", "r1", "w2", "r2"] + + def test_path_scoped_tool_without_path_is_a_barrier(self): + calls = [ + _tc("read_file", "{}", call_id="nopath"), + _tc("web_search", call_id="r1"), + _tc("web_search", call_id="r2"), + ] + segments = _plan_tool_batch_segments(calls) + assert _kinds(segments) == ["sequential", "parallel"] + + def test_flattened_segments_always_preserve_emission_order(self): + calls = [ + _tc("terminal", '{"command":"x"}', call_id="b1"), + _tc("web_search", call_id="r1"), + _tc("clarify", '{"question":"?"}', call_id="c1"), + _tc("read_file", '{"path":"a.py"}', call_id="r2"), + _tc("read_file", '{"path":"b.py"}', call_id="r3"), + ] + segments = _plan_tool_batch_segments(calls) + assert _flatten_ids(segments) == ["b1", "r1", "c1", "r2", "r3"] + + +class TestShouldParallelizeBackwardCompat: + """The boolean gate is now a view over the planner — same answers as before.""" + + def test_single_call_is_sequential(self): + assert not _should_parallelize_tool_batch([_tc("web_search")]) + + def test_all_safe_batch_is_parallel(self): + assert _should_parallelize_tool_batch([_tc("web_search"), _tc("web_extract")]) + + def test_mixed_batch_is_not_wholly_parallel(self): + assert not _should_parallelize_tool_batch( + [_tc("web_search"), _tc("terminal", '{"command":"ls"}')] + ) + + def test_clarify_anywhere_blocks_whole_batch_parallelism(self): + assert not _should_parallelize_tool_batch( + [_tc("web_search"), _tc("clarify", '{"question":"?"}')] + ) + + +# --------------------------------------------------------------------------- +# Dispatcher integration +# --------------------------------------------------------------------------- + + +def _make_tool_defs(*names: str) -> list: + return [ + { + "type": "function", + "function": { + "name": n, + "description": f"{n} tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + for n in names + ] + + +@pytest.fixture() +def agent(): + with ( + patch( + "run_agent.get_tool_definitions", + return_value=_make_tool_defs("web_search", "terminal"), + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + a = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + a.client = MagicMock() + return a + + +class TestSegmentedDispatchIntegration: + def test_mixed_batch_runs_safe_prefix_concurrently_and_barrier_after(self, agent): + """Two web_search calls must overlap in time; terminal must start only + after both finish; results land in the model's emission order.""" + calls = [ + _tc("web_search", '{"query":"a"}', call_id="s1"), + _tc("web_search", '{"query":"b"}', call_id="s2"), + _tc("terminal", '{"command":"echo done"}', call_id="t1"), + ] + msg = SimpleNamespace(content="", tool_calls=calls) + messages = [] + + rendezvous = threading.Barrier(2, timeout=10) + events = [] + events_lock = threading.Lock() + + def fake_handle(name, args, task_id, **kwargs): + with events_lock: + events.append(("start", name, kwargs["tool_call_id"])) + if name == "web_search": + # Both searches must be in flight at once to pass this + # barrier — proves genuine concurrency for the safe prefix. + rendezvous.wait() + with events_lock: + events.append(("end", name, kwargs["tool_call_id"])) + return json.dumps({"ok": name}) + + with patch("run_agent.handle_function_call", side_effect=fake_handle): + agent._execute_tool_calls(msg, messages, "task-1") + + # One result per call, in emission order. + assert [m["tool_call_id"] for m in messages] == ["s1", "s2", "t1"] + assert all(m["role"] == "tool" for m in messages) + + # The barrier (terminal) started only after BOTH searches ended. + terminal_start = events.index(("start", "terminal", "t1")) + search_ends = [ + i for i, e in enumerate(events) if e[0] == "end" and e[1] == "web_search" + ] + assert len(search_ends) == 2 + assert all(i < terminal_start for i in search_ends) + + def test_mixed_batch_preserves_order_with_barrier_in_middle(self, agent): + calls = [ + _tc("web_search", '{"query":"a"}', call_id="s1"), + _tc("web_search", '{"query":"b"}', call_id="s2"), + _tc("terminal", '{"command":"touch x"}', call_id="t1"), + _tc("web_search", '{"query":"c"}', call_id="s3"), + _tc("web_search", '{"query":"d"}', call_id="s4"), + ] + msg = SimpleNamespace(content="", tool_calls=calls) + messages = [] + executed = [] + lock = threading.Lock() + + def fake_handle(name, args, task_id, **kwargs): + with lock: + executed.append(kwargs["tool_call_id"]) + return json.dumps({"ok": True}) + + with patch("run_agent.handle_function_call", side_effect=fake_handle): + agent._execute_tool_calls(msg, messages, "task-1") + + assert [m["tool_call_id"] for m in messages] == ["s1", "s2", "t1", "s3", "s4"] + # Barrier ordering: t1 executed after {s1,s2} and before {s3,s4}. + t1_pos = executed.index("t1") + assert {"s1", "s2"} == set(executed[:t1_pos]) + assert {"s3", "s4"} == set(executed[t1_pos + 1:]) + + def test_homogeneous_safe_batch_still_uses_plain_concurrent_path(self, agent): + calls = [_tc("web_search", '{"query":"a"}'), _tc("web_search", '{"query":"b"}')] + msg = SimpleNamespace(content="", tool_calls=calls) + + with ( + patch.object(agent, "_execute_tool_calls_concurrent") as conc, + patch.object(agent, "_execute_tool_calls_sequential") as seq, + ): + agent._execute_tool_calls(msg, [], "task-1") + + conc.assert_called_once() + seq.assert_not_called() + + def test_homogeneous_unsafe_batch_still_uses_plain_sequential_path(self, agent): + calls = [ + _tc("terminal", '{"command":"a"}'), + _tc("terminal", '{"command":"b"}'), + ] + msg = SimpleNamespace(content="", tool_calls=calls) + + with ( + patch.object(agent, "_execute_tool_calls_concurrent") as conc, + patch.object(agent, "_execute_tool_calls_sequential") as seq, + ): + agent._execute_tool_calls(msg, [], "task-1") + + seq.assert_called_once() + conc.assert_not_called() + + def test_single_call_uses_sequential_path(self, agent): + msg = SimpleNamespace(content="", tool_calls=[_tc("web_search", '{"query":"a"}')]) + + with ( + patch.object(agent, "_execute_tool_calls_concurrent") as conc, + patch.object(agent, "_execute_tool_calls_sequential") as seq, + ): + agent._execute_tool_calls(msg, [], "task-1") + + seq.assert_called_once() + conc.assert_not_called() + + def test_interrupt_during_barrier_drains_later_segments(self, agent): + """Interrupt raised while the barrier tool runs: the trailing parallel + segment must be drained with cancelled results — one per call — + without executing.""" + calls = [ + _tc("web_search", '{"query":"a"}', call_id="s1"), + _tc("web_search", '{"query":"b"}', call_id="s2"), + _tc("terminal", '{"command":"long"}', call_id="t1"), + _tc("web_search", '{"query":"c"}', call_id="s3"), + _tc("web_search", '{"query":"d"}', call_id="s4"), + ] + msg = SimpleNamespace(content="", tool_calls=calls) + messages = [] + executed = [] + lock = threading.Lock() + + def fake_handle(name, args, task_id, **kwargs): + with lock: + executed.append(kwargs["tool_call_id"]) + if kwargs["tool_call_id"] == "t1": + agent._interrupt_requested = True + return json.dumps({"ok": True}) + + with patch("run_agent.handle_function_call", side_effect=fake_handle): + agent._execute_tool_calls(msg, messages, "task-1") + + # Every call still gets exactly one result, in order. + assert [m["tool_call_id"] for m in messages] == ["s1", "s2", "t1", "s3", "s4"] + # s3/s4 were never executed. + assert "s3" not in executed and "s4" not in executed + for m in messages[-2:]: + assert "cancelled" in m["content"] or "skipped" in m["content"] + + def test_steer_lands_exactly_once_in_mixed_batch(self, agent): + """Steer is drained once (per-tool drains + one dispatcher-level + finalize) — the marker must appear exactly once across the batch, + never duplicated by segment boundaries.""" + calls = [ + _tc("web_search", '{"query":"a"}', call_id="s1"), + _tc("web_search", '{"query":"b"}', call_id="s2"), + _tc("terminal", '{"command":"echo hi"}', call_id="t1"), + ] + msg = SimpleNamespace(content="", tool_calls=calls) + messages = [] + + def fake_handle(name, args, task_id, **kwargs): + return json.dumps({"ok": True}) + + agent.steer("focus on the tests") + with patch("run_agent.handle_function_call", side_effect=fake_handle): + agent._execute_tool_calls(msg, messages, "task-1") + + contents = [m["content"] for m in messages] + hits = [c for c in contents if "focus on the tests" in c] + assert len(hits) == 1 diff --git a/tests/run_agent/test_verification_continuation_budget.py b/tests/run_agent/test_verification_continuation_budget.py new file mode 100644 index 000000000000..d6f65407e7ab --- /dev/null +++ b/tests/run_agent/test_verification_continuation_budget.py @@ -0,0 +1,146 @@ +"""End-to-end regression coverage for verification budget exhaustion (#61631).""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent + + +def _response(content="composed report"): + message = SimpleNamespace(content=content, tool_calls=None) + return SimpleNamespace( + choices=[SimpleNamespace(message=message, finish_reason="stop")], + model="test/model", + usage=None, + ) + + +@pytest.fixture +def agent(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + instance = AIAgent( + session_id="verify-budget-test", + api_key="test-key", + base_url="https://example.invalid/v1", + provider="openai-compat", + model="test/model", + max_iterations=1, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + instance._cached_system_prompt = "stable test prompt" + instance._session_db = None + instance._session_json_enabled = False + instance.save_trajectories = False + instance.compression_enabled = False + instance._cleanup_task_resources = lambda *_a, **_kw: None + instance._save_trajectory = lambda *_a, **_kw: None + return instance + + +def _assert_pending_response_survives(agent, result): + assert result["final_response"] == "composed report" + assert result["turn_exit_reason"] == "max_iterations_reached(1/1)" + assert result["completed"] is False + assert agent._handle_max_iterations.call_count == 0 + assert [message["role"] for message in result["messages"]] == [ + "user", + "assistant", + "user", + "assistant", + ] + + +def test_verify_on_stop_preserves_composed_report_at_budget_limit(agent, monkeypatch): + def model_call(_api_kwargs): + agent._turn_file_mutation_paths = {"changed.py"} + return _response() + + agent._interruptible_api_call = model_call + agent._handle_max_iterations = MagicMock(return_value="replacement summary") + monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "1") + + with ( + patch("agent.verification_stop.build_verify_on_stop_nudge", return_value="verify it"), + patch("hermes_cli.plugins.invoke_hook", return_value=[]), + ): + result = agent.run_conversation("edit changed.py") + + _assert_pending_response_survives(agent, result) + assert result["messages"][1]["_verification_stop_synthetic"] is True + assert result["messages"][2]["_verification_stop_synthetic"] is True + + +def test_pre_verify_preserves_composed_report_at_budget_limit(agent, monkeypatch): + def model_call(_api_kwargs): + agent._turn_file_mutation_paths = {"changed.py"} + return _response() + + agent._interruptible_api_call = model_call + agent._handle_max_iterations = MagicMock(return_value="replacement summary") + monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "0") + + with ( + patch("hermes_cli.plugins.has_hook", side_effect=lambda name: name == "pre_verify"), + patch( + "hermes_cli.plugins.get_pre_verify_continue_message", + return_value="run project tests", + ), + patch("agent.verify_hooks.max_verify_nudges", return_value=2), + patch("hermes_cli.plugins.invoke_hook", return_value=[]), + ): + result = agent.run_conversation("edit changed.py") + + _assert_pending_response_survives(agent, result) + assert result["messages"][1]["_pre_verify_synthetic"] is True + assert result["messages"][2]["_pre_verify_synthetic"] is True + + +def test_intermediate_ack_uses_summary_instead_of_premature_text(agent, monkeypatch): + agent.valid_tool_names = ["web_search"] + agent._intent_ack_continuation = True + agent._looks_like_codex_intermediate_ack = MagicMock(return_value=True) + agent._interruptible_api_call = lambda _kwargs: _response("I'll inspect the files now") + agent._handle_max_iterations = MagicMock(return_value="verified summary.") + monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "0") + + with ( + patch("hermes_cli.plugins.has_hook", return_value=False), + patch("hermes_cli.plugins.invoke_hook", return_value=[]), + ): + result = agent.run_conversation("inspect /tmp/project") + + assert result["final_response"] == "verified summary." + assert result["turn_exit_reason"] == "max_iterations_reached(1/1)" + agent._handle_max_iterations.assert_called_once() + + +def test_later_verified_response_supersedes_pending_report(agent, monkeypatch): + agent.max_iterations = 2 + agent.iteration_budget.max_total = 2 + answers = iter([_response("premature report"), _response("verified final report")]) + agent._interruptible_api_call = lambda _kwargs: next(answers) + agent._handle_max_iterations = MagicMock(return_value="replacement summary") + monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "1") + + with ( + patch( + "agent.verification_stop.build_verify_on_stop_nudge", + side_effect=["verify it", None], + ), + patch("hermes_cli.plugins.invoke_hook", return_value=[]), + ): + result = agent.run_conversation("edit changed.py") + + assert result["final_response"] == "verified final report" + assert result["turn_exit_reason"] == "text_response(finish_reason=stop)" + assert result["completed"] is True + agent._handle_max_iterations.assert_not_called() diff --git a/tests/run_agent/test_wait_state_visibility.py b/tests/run_agent/test_wait_state_visibility.py new file mode 100644 index 000000000000..ade5babcf5c5 --- /dev/null +++ b/tests/run_agent/test_wait_state_visibility.py @@ -0,0 +1,123 @@ +"""Tests for wait-state visibility — the live "what are we waiting on" notices. + +Long provider waits (slow/overloaded backend, no first byte, reasoning model +thinking for minutes) used to leave CLI/TUI/Desktop users staring at a generic +"cogitating..." spinner with no explanation. ``AIAgent._emit_wait_notice`` +rewrites the live spinner/status line (via ``thinking_callback``, bridged to +``thinking.delta`` for TUI/Desktop) and updates the activity tracker (which the +gateway's "⏳ Working — N min" heartbeat includes). +""" + +from __future__ import annotations + +import sys +import time +import types +from types import SimpleNamespace + +import pytest + +# Stub optional heavy imports so run_agent imports cleanly in isolation. +sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None)) +sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object)) +sys.modules.setdefault("fal_client", types.SimpleNamespace()) + + +def _make_agent(tmp_path, monkeypatch, **kwargs): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / ".env").write_text("", encoding="utf-8") + (tmp_path / "config.yaml").write_text("{}\n", encoding="utf-8") + from run_agent import AIAgent + + return AIAgent( + model="test-model", + api_key="sk-dummy", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + platform="cli", + **kwargs, + ) + + +def test_emit_wait_notice_updates_spinner_and_activity(tmp_path, monkeypatch): + """The notice reaches the live display callback AND the activity tracker.""" + seen: list = [] + agent = _make_agent(tmp_path, monkeypatch, thinking_callback=seen.append) + + agent._emit_wait_notice("⏳ waiting on test-model — 30s with no response yet") + + assert seen == ["⏳ waiting on test-model — 30s with no response yet"] + summary = agent.get_activity_summary() + assert "waiting on test-model" in summary["last_activity_desc"] + + +def test_emit_wait_notice_without_callback_still_touches_activity(tmp_path, monkeypatch): + """No thinking_callback bound (gateway sessions) — activity still updates.""" + agent = _make_agent(tmp_path, monkeypatch) + agent.thinking_callback = None + + agent._emit_wait_notice("⏳ waiting on test-model — 60s") + + assert "waiting on test-model" in agent.get_activity_summary()["last_activity_desc"] + + +def test_emit_wait_notice_swallows_callback_errors(tmp_path, monkeypatch): + """A broken display callback must never break the API-call wait loop.""" + + def _boom(text): + raise RuntimeError("display exploded") + + agent = _make_agent(tmp_path, monkeypatch, thinking_callback=_boom) + + agent._emit_wait_notice("⏳ waiting") # must not raise + assert "waiting" in agent.get_activity_summary()["last_activity_desc"] + + +def test_nonstream_wait_loop_emits_explained_notice(tmp_path, monkeypatch): + """After ~30s with no response, interruptible_api_call rewrites the live + line with an explanation (model name, elapsed, overload hint, recovery + deadline) instead of a bare 'waiting for non-streaming response'.""" + from agent import chat_completion_helpers as h + + seen: list = [] + agent = _make_agent(tmp_path, monkeypatch, thinking_callback=seen.append) + agent.api_mode = "codex_responses" + monkeypatch.setattr(agent, "_compute_non_stream_stale_timeout", lambda *a, **k: 60.0) + + # Compress the 30s cadence: the loop fires the notice every 100 polls of + # 0.3s; patch the join timeout down via a tiny thread that stays alive + # briefly, and shrink the poll interval by patching time. Simplest + # reliable approach: run a worker that hangs ~1.2s and patch the modulo + # counter trigger by making the loop's join timeout effectively immediate. + dummy_client = SimpleNamespace() + monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client) + monkeypatch.setattr(agent, "_abort_request_openai_client", lambda c, reason=None: None) + monkeypatch.setattr(agent, "_close_request_openai_client", lambda c, reason=None: None) + + stop = {"flag": False} + + def fake_hang(api_kwargs, client=None, on_first_delta=None): + deadline = time.time() + 10 + while time.time() < deadline and not stop["flag"] and not agent._interrupt_requested: + time.sleep(0.02) + raise RuntimeError("connection closed") + + monkeypatch.setattr(agent, "_run_codex_stream", fake_hang) + # TTFB kill at 1s ends the call quickly; the wait notice fires on the + # 100-poll cadence, so to observe it within the 1s window we shrink the + # cadence by patching threading.Thread.join used in the poll loop is + # overkill — instead just verify the TTFB reconnect notice, which flows + # through the same _emit_wait_notice path. + monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "1") + + try: + with pytest.raises(TimeoutError): + h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": "hi"}) + finally: + stop["flag"] = True + + reconnect_notices = [s for s in seen if "reconnecting" in s] + assert reconnect_notices, f"expected a reconnect wait-notice, saw: {seen}" + assert "no response from provider" in reconnect_notices[0] diff --git a/tests/test_assistant_ui_tap_compat.py b/tests/test_assistant_ui_tap_compat.py deleted file mode 100644 index 57c9e874819f..000000000000 --- a/tests/test_assistant_ui_tap_compat.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Invariant: the @assistant-ui dependency cluster agrees on one tap version. - -The Hermes desktop app (``apps/desktop``) is built from source on every -install/update via ``scripts/install.ps1`` → ``npm ci``/``npm install`` → -``tsc -b && vite build``. The ``@assistant-ui`` packages share an internal -reactivity lib, ``@assistant-ui/tap``, and they only interoperate when they -all resolve the *same* tap version: - -* ``@assistant-ui/react@0.12.28`` and ``@assistant-ui/core`` pin - ``@assistant-ui/tap@^0.5.x`` (which exports ``.`` and ``./react``). -* ``@assistant-ui/store@0.2.18`` bumped its tap peer to ``^0.9.0`` and started - importing ``@assistant-ui/tap/react-shim`` — an entry point that only exists - in the tap ``0.9.x`` line. - -Because ``react@0.12.28`` requests ``store@^0.2.9`` (a caret range), a fresh -install silently floated ``store`` up to ``0.2.18``, which then could not find -``./react-shim`` in the hoisted ``tap@0.5.x`` and crashed ``vite build`` with:: - - "./react-shim" is not exported ... from package @assistant-ui/tap - -i.e. the opaque "apps/desktop build failed (exit 1)" every user hit when -updating. The fix pins ``@assistant-ui/store`` (via root ``overrides``) to the -last release that targets ``tap@^0.5.x``. - -This is a *contract* test, not a snapshot: it does not assert specific version -numbers, only that whatever tap the lockfile hoists satisfies every -``@assistant-ui/*`` package's declared tap requirement. It fails if any future -bump reintroduces a split tap requirement across the cluster. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - - -REPO_ROOT = Path(__file__).resolve().parent.parent -TAP = "@assistant-ui/tap" - - -def _caret_satisfies(version: str, spec: str) -> bool: - """Minimal npm semver check for the ranges this cluster actually uses. - - Supports exact versions, ``^x.y.z`` (with correct 0.x semantics), and - ``||`` unions. Pre-release tags are ignored (none are used here). - """ - - def parse(v: str) -> tuple[int, int, int]: - core = v.lstrip("^~>= 0: - hi = (major + 1, 0, 0) - elif minor > 0: - hi = (0, minor + 1, 0) - else: - hi = (0, 0, lo[2] + 1) - if ver < hi: - return True - elif clause[0].isdigit() or clause.startswith("v"): - if ver == parse(clause): - return True - return False - - -def _lock_packages() -> dict: - lock_path = REPO_ROOT / "package-lock.json" - if not lock_path.exists(): - pytest.skip("package-lock.json not materialized in this CI shard") - with lock_path.open("r", encoding="utf-8") as fh: - return json.load(fh).get("packages", {}) - - -def _hoisted_tap_version(packages: dict) -> str: - entry = packages.get(f"node_modules/{TAP}") - assert entry is not None, ( - "package-lock.json has no hoisted node_modules/@assistant-ui/tap " - "entry — the @assistant-ui cluster should resolve a single shared " - "tap version." - ) - return entry["version"] - - -def test_assistant_ui_cluster_agrees_on_one_tap() -> None: - """Every @assistant-ui/* package's tap requirement must be satisfiable. - - Encodes the contract that broke the desktop build: a single hoisted - @assistant-ui/tap must satisfy the tap range declared by react, core, - store, and any sibling — otherwise the missing ``./react-shim`` export - (or a similar API split) breaks ``vite build``. - """ - packages = _lock_packages() - tap_version = _hoisted_tap_version(packages) - - offenders: list[str] = [] - for key, meta in packages.items(): - name = key.rsplit("node_modules/", 1)[-1] - if not name.startswith("@assistant-ui/") or name == TAP: - continue - peer_meta = meta.get("peerDependenciesMeta", {}).get(TAP, {}) - if peer_meta.get("optional"): - continue - spec = meta.get("dependencies", {}).get(TAP) or meta.get( - "peerDependencies", {} - ).get(TAP) - if not spec: - continue - if not _caret_satisfies(tap_version, spec): - offenders.append(f"{name} requires {TAP}{spec!r}") - - assert not offenders, ( - f"Hoisted {TAP}@{tap_version} does not satisfy: " - + "; ".join(offenders) - + ". The @assistant-ui cluster has split tap requirements — pin the " - "offending package (e.g. via root package.json `overrides`) so the " - "whole cluster shares one tap line. See this test's module docstring." - ) - - -def test_caret_satisfies_helper() -> None: - """Guard the tiny semver helper the invariant relies on.""" - assert _caret_satisfies("0.5.14", "^0.5.10") - assert _caret_satisfies("0.5.14", "^0.5.14") - assert not _caret_satisfies("0.5.14", "^0.9.0") - assert not _caret_satisfies("0.5.14", "^0.6.0") - assert _caret_satisfies("1.2.5", "^1.2.0") - assert not _caret_satisfies("2.0.0", "^1.2.0") - assert _caret_satisfies("0.5.14", "^0.5.0 || ^0.9.0") diff --git a/tests/test_dashboard_sidecar_close_on_disconnect.py b/tests/test_dashboard_sidecar_close_on_disconnect.py deleted file mode 100644 index b2eb33645f29..000000000000 --- a/tests/test_dashboard_sidecar_close_on_disconnect.py +++ /dev/null @@ -1,25 +0,0 @@ -import re -from pathlib import Path - -CHAT_SIDEBAR = Path(__file__).resolve().parent.parent / "web/src/components/ChatSidebar.tsx" - - -def test_sidecar_session_create_requests_close_on_disconnect(): - """The sidecar must opt its session into close_on_disconnect so the gateway - reaps the slash_worker on WS disconnect (the #21370/#21467 leak).""" - source = CHAT_SIDEBAR.read_text(encoding="utf-8") - call = re.search(r'"session\.create",\s*\{(.*?)\}', source, re.DOTALL) - assert call, "sidecar session.create call not found" - assert re.search(r"close_on_disconnect:\s*true", call.group(1)) - - -def test_sidecar_session_create_scopes_profile(): - """The sidecar must pass the dashboard's selected profile so model/credential - info matches the PTY child under profile-scoped chat.""" - source = CHAT_SIDEBAR.read_text(encoding="utf-8") - call = re.search(r'"session\.create",\s*\{(.*?)\}\);', source, re.DOTALL) - assert call, "sidecar session.create call not found" - body = call.group(1) - assert re.search(r"close_on_disconnect:\s*true", body) - assert re.search(r'source:\s*"tool"', body) - assert re.search(r"\.\.\.\(profile\s*\?\s*\{\s*profile\s*\}\s*:\s*\{\}\)", body) diff --git a/tests/test_desktop_electron_pin.py b/tests/test_desktop_electron_pin.py deleted file mode 100644 index 2943dc9c9feb..000000000000 --- a/tests/test_desktop_electron_pin.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Regression: the desktop Electron dependency must be an exact, consistent pin. - -The Windows desktop install failed at "Building desktop app" because Electron -changed its install mechanism mid patch-series: - - electron 40.9.3 .. 40.10.2 -> @electron/get@^2 + extract-zip@^2 (pure JS) - electron 40.10.3 / 40.10.4 -> @electron/get@^5 + - @electron-internal/extract-zip@^1 (native napi) - -``apps/desktop/package.json`` declared ``electronVersion: 40.9.3`` (the tested, -JS-extract build) but pinned the dependency loosely as ``electron: ^40.9.3``. -``npm ci`` then resolved 40.10.3/40.10.4 — the new *native* extract-zip whose -win32-x64 binding fails to ``dlopen`` on some Windows hosts -(``ERR_DLOPEN_FAILED loading index.win32-x64-msvc.node``). - -These tests lock the contract that prevents that drift, without hard-coding the -specific version (which is allowed to move): - -1. the Electron dependency is an *exact* version (Electron Builder needs the - installed binary to match ``electronVersion`` / ``electronDist``), and -2. the dependency, ``build.electronVersion``, and the resolved lockfile entry - all agree — so ``npm ci`` installs exactly what the build packages. -""" - -from __future__ import annotations - -import json -import re -from pathlib import Path - -import pytest - - -REPO_ROOT = Path(__file__).resolve().parent.parent -DESKTOP_PKG = REPO_ROOT / "apps" / "desktop" / "package.json" -ROOT_LOCK = REPO_ROOT / "package-lock.json" - -# An exact semver: digits.digits.digits with an optional prerelease/build tag, -# but NO range operators (^ ~ > < = * x || spaces || -range). -_EXACT_SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") - - -def _desktop_pkg() -> dict: - assert DESKTOP_PKG.is_file(), f"missing {DESKTOP_PKG}" - return json.loads(DESKTOP_PKG.read_text(encoding="utf-8")) - - -def _electron_spec(pkg: dict) -> str: - for section in ("dependencies", "devDependencies"): - spec = pkg.get(section, {}).get("electron") - if spec: - return spec - pytest.fail("electron is not listed in apps/desktop dependencies") - - -def test_electron_dependency_is_exactly_pinned(): - """A loose range lets npm drift onto an Electron with a different installer.""" - spec = _electron_spec(_desktop_pkg()) - assert _EXACT_SEMVER.match(spec), ( - f"electron must be pinned to an exact version, got {spec!r}. " - "A range (^/~) lets npm ci resolve a newer Electron whose postinstall " - "may differ from the one the build was validated against." - ) - - -def test_electron_dependency_matches_electron_version(): - """electron-builder packages build.electronVersion against the installed binary.""" - pkg = _desktop_pkg() - spec = _electron_spec(pkg) - builder_version = pkg.get("build", {}).get("electronVersion") - assert builder_version, "build.electronVersion is missing" - assert spec == builder_version, ( - f"electron dependency ({spec!r}) must equal build.electronVersion " - f"({builder_version!r}); otherwise electron-builder packages a different " - "version than npm installs into electronDist." - ) - - -def test_lockfile_resolves_the_pinned_electron(): - """npm ci installs from the lockfile, so it must agree with the pin.""" - if not ROOT_LOCK.is_file(): - pytest.skip("root package-lock.json not present") - spec = _electron_spec(_desktop_pkg()) - lock = json.loads(ROOT_LOCK.read_text(encoding="utf-8")) - packages = lock.get("packages", {}) - resolved = [ - meta.get("version") - for path, meta in packages.items() - if path.endswith("node_modules/electron") and meta.get("version") - ] - assert resolved, "no electron entry found in package-lock.json" - assert all(v == spec for v in resolved), ( - f"package-lock.json resolves electron to {sorted(set(resolved))}, " - f"but the pin is {spec!r}; run `npm install --package-lock-only` so " - "`npm ci` stays consistent." - ) - - -DESKTOP_DIR = REPO_ROOT / "apps" / "desktop" -ELECTRON_BUILDER_WRAPPER = DESKTOP_DIR / "scripts" / "run-electron-builder.cjs" - - -def test_no_static_electron_dist_that_can_drift(): - """build.electronDist must not be a static path — hoisting is non-deterministic.""" - assert "electronDist" not in _desktop_pkg().get("build", {}), ( - "build.electronDist is hardcoded again. npm hoisting is non-deterministic, " - "so a static path silently breaks packaging when the layout changes. Let " - "scripts/run-electron-builder.cjs resolve it dynamically instead." - ) - - -def test_builder_script_routes_through_dynamic_resolver(): - """npm run builder must invoke run-electron-builder.cjs, not bare electron-builder.""" - builder = _desktop_pkg().get("scripts", {}).get("builder", "") - assert "run-electron-builder.cjs" in builder, ( - f"the 'builder' script must run scripts/run-electron-builder.cjs, got " - f"{builder!r}" - ) - assert ELECTRON_BUILDER_WRAPPER.is_file(), ( - f"missing dynamic-resolver wrapper at {ELECTRON_BUILDER_WRAPPER}" - ) - - -def test_resolver_uses_node_module_resolution(): - """Wrapper must resolve electron via require.resolve and pass -c.electronDist.""" - src = ELECTRON_BUILDER_WRAPPER.read_text(encoding="utf-8") - assert 'require.resolve("electron/package.json")' in src, ( - "run-electron-builder.cjs must resolve electron via " - "require.resolve('electron/package.json') to stay hoist-proof." - ) - # And it must hand the resolved dist to electron-builder as an override. - assert "-c.electronDist=" in src, ( - "run-electron-builder.cjs must pass the resolved dist to electron-builder " - "via -c.electronDist." - ) diff --git a/tests/test_desktop_mac_entitlements.py b/tests/test_desktop_mac_entitlements.py deleted file mode 100644 index 5877d5b257c2..000000000000 --- a/tests/test_desktop_mac_entitlements.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Regression for #37718: macOS microphone entitlement must be inherited. - -Hermes Desktop signs with ``hardenedRuntime: true`` and points electron-builder -at two entitlement files (see ``apps/desktop/package.json``): - -* ``entitlements`` → ``electron/entitlements.mac.plist`` (the main app), and -* ``entitlementsInherit`` → ``electron/entitlements.mac.inherit.plist`` (the - Electron Helper / Setup processes). - -Under the hardened runtime, the process that actually opens the microphone is a -Helper, which inherits the *inherit* plist. ``com.apple.security.device.audio-input`` -lived only in the main plist, so macOS' TCC layer refused the microphone with:: - - Prompting policy for hardened runtime; service: kTCCServiceMicrophone - requires entitlement com.apple.security.device.audio-input but it is missing - -and never showed the permission prompt. These tests pin that every device -entitlement granted to the main app is also granted to the inherited helpers. -""" - -from __future__ import annotations - -import plistlib -from pathlib import Path - -import pytest - - -REPO_ROOT = Path(__file__).resolve().parent.parent -ELECTRON_DIR = REPO_ROOT / "apps" / "desktop" / "electron" -MAIN_PLIST = ELECTRON_DIR / "entitlements.mac.plist" -INHERIT_PLIST = ELECTRON_DIR / "entitlements.mac.inherit.plist" - -DEVICE_PREFIX = "com.apple.security.device." - - -def _load(plist: Path) -> dict: - assert plist.is_file(), f"missing entitlements file: {plist}" - with plist.open("rb") as fh: - return plistlib.load(fh) - - -def test_inherit_plist_grants_microphone() -> None: - """The helper-inherited plist must grant audio-input (regression #37718).""" - inherit = _load(INHERIT_PLIST) - assert inherit.get("com.apple.security.device.audio-input") is True, ( - "entitlements.mac.inherit.plist must grant " - "`com.apple.security.device.audio-input`; without it the hardened-runtime " - "Helper process is denied the microphone and no TCC prompt appears (#37718)." - ) - - -def test_device_entitlements_are_inherited() -> None: - """Every device.* entitlement on the main app must also be inherited.""" - main = _load(MAIN_PLIST) - inherit = _load(INHERIT_PLIST) - - main_device = { - key: val - for key, val in main.items() - if key.startswith(DEVICE_PREFIX) and val is True - } - missing = [key for key in main_device if inherit.get(key) is not True] - assert not missing, ( - "Device entitlements present in entitlements.mac.plist but missing from " - f"entitlements.mac.inherit.plist: {missing}. Helper/Setup processes inherit " - "the latter under hardenedRuntime, so any device access the app needs must " - "be listed in both (#37718)." - ) - - -@pytest.mark.parametrize("plist", [MAIN_PLIST, INHERIT_PLIST]) -def test_entitlement_files_are_valid_plists(plist: Path) -> None: - """Both entitlement files must remain well-formed plist dictionaries.""" - data = _load(plist) - assert isinstance(data, dict) and data, f"{plist.name} should be a non-empty dict" diff --git a/tests/test_empty_model_fallback.py b/tests/test_empty_model_fallback.py index a15666ecb604..53260f655ed9 100644 --- a/tests/test_empty_model_fallback.py +++ b/tests/test_empty_model_fallback.py @@ -13,12 +13,17 @@ class TestGetDefaultModelForProvider: assert result assert isinstance(result, str) - def test_openrouter_returns_empty(self): - """OpenRouter uses dynamic model fetch, no static catalog entry.""" - from hermes_cli.models import get_default_model_for_provider - # OpenRouter is not in _PROVIDER_MODELS — it uses live fetching + def test_openrouter_returns_preferred_silent_default(self): + """OpenRouter has no static catalog (live fetch), but the silent + default must still resolve — to the cost-safe preferred model, never + the curated list's Anthropic flagship (claude-fable-5).""" + from hermes_cli.models import ( + PREFERRED_SILENT_DEFAULT_MODEL, + get_default_model_for_provider, + ) result = get_default_model_for_provider("openrouter") - assert result == "" + assert result == PREFERRED_SILENT_DEFAULT_MODEL + assert "claude" not in result.lower() def test_unknown_provider_returns_empty(self): from hermes_cli.models import get_default_model_for_provider @@ -33,14 +38,14 @@ class TestGetDefaultModelForProvider: def test_nous_silent_default_is_not_the_expensive_flagship(self): """Nous Portal is a metered aggregator whose curated list is ordered most-capable-first, so entry [0] is the priciest flagship - (anthropic/claude-opus-4.8). The silent fallback (provider set, no model) + (anthropic/claude-fable-5). The silent fallback (provider set, no model) must NOT escalate to it — otherwise an unconfigured profile silently bills the most expensive model. Regression for the billing footgun. """ from hermes_cli.models import ( _PROVIDER_MODELS, - _PROVIDER_SILENT_DEFAULT_OVERRIDES, get_default_model_for_provider, + get_preferred_silent_default_model, ) result = get_default_model_for_provider("nous") @@ -48,27 +53,108 @@ class TestGetDefaultModelForProvider: assert "opus" not in result.lower(), ( f"silent default escalated to an expensive flagship: {result!r}" ) + assert "claude" not in result.lower(), ( + f"silent default escalated to an expensive flagship: {result!r}" + ) assert result != _PROVIDER_MODELS["nous"][0], ( "silent default must not be the most-capable/priciest catalog entry" ) - # The override must point at a model that actually exists in the catalog. - assert result == _PROVIDER_SILENT_DEFAULT_OVERRIDES["nous"] + # The default must resolve through the catalog-label helper and point + # at a model that actually exists in the curated catalog. + assert result == get_preferred_silent_default_model("nous") assert result in _PROVIDER_MODELS["nous"] - def test_override_falls_back_to_catalog_when_missing(self): - """If an override model is no longer in the catalog, fall back to [0] - rather than returning a stale/absent id.""" + def test_catalog_label_overrides_constant(self): + """A ``"default": true`` label in the cached catalog manifest wins over + the in-repo constant, so maintainers can rotate the silent default + without shipping a release.""" from unittest.mock import patch from hermes_cli import models as models_mod - with patch.dict( - models_mod._PROVIDER_SILENT_DEFAULT_OVERRIDES, - {"openai-codex": "does-not-exist-model"}, - clear=False, + with patch( + "hermes_cli.model_catalog.get_default_model_from_cache", + return_value="qwen/qwen3.7-plus", ): - result = models_mod.get_default_model_for_provider("openai-codex") - assert result == models_mod._PROVIDER_MODELS["openai-codex"][0] + assert ( + models_mod.get_preferred_silent_default_model("nous") + == "qwen/qwen3.7-plus" + ) + # nous catalog carries qwen3.7-plus, so the full resolver follows. + assert ( + models_mod.get_default_model_for_provider("nous") + == "qwen/qwen3.7-plus" + ) + + def test_no_catalog_cache_falls_back_to_constant(self): + """With no cached manifest (fresh install / offline), the in-repo + constant is the silent default.""" + from unittest.mock import patch + + from hermes_cli import models as models_mod + + with patch( + "hermes_cli.model_catalog.get_default_model_from_cache", + return_value=None, + ): + assert ( + models_mod.get_preferred_silent_default_model("openrouter") + == models_mod.PREFERRED_SILENT_DEFAULT_MODEL + ) + + def test_stale_label_not_in_catalog_falls_back(self): + """If the labeled default model is no longer in the provider's curated + catalog, fall back to entry [0] rather than returning an absent id.""" + from unittest.mock import patch + + from hermes_cli import models as models_mod + + with patch( + "hermes_cli.model_catalog.get_default_model_from_cache", + return_value="does-not-exist-model", + ): + result = models_mod.get_default_model_for_provider("nous") + assert result == models_mod._PROVIDER_MODELS["nous"][0] + + +class TestDetectStaticProviderCostSafeDefault: + """detect_static_provider_for_model must apply the same cost-safe default + as get_default_model_for_provider when a bare provider name is typed as a + model (e.g. ``/model nous``).""" + + def test_bare_nous_does_not_escalate_to_flagship(self): + from hermes_cli.models import ( + _PROVIDER_MODELS, + get_default_model_for_provider, + detect_static_provider_for_model, + ) + + result = detect_static_provider_for_model("nous", "openrouter") + assert result is not None + provider, model = result + assert provider == "nous" + # Must match the cost-safe silent default, NOT the priciest catalog + # entry [0]. Regression: this path returned _PROVIDER_MODELS["nous"][0] + # directly, re-introducing the billing footgun on the interactive + # ``/model nous`` path. + assert model == get_default_model_for_provider("nous") + assert "opus" not in model.lower() + assert model != _PROVIDER_MODELS["nous"][0] + + def test_provider_without_override_still_uses_first_model(self): + """Providers outside _SILENT_DEFAULT_PROVIDERS are unchanged.""" + from hermes_cli.models import ( + _PROVIDER_MODELS, + _SILENT_DEFAULT_PROVIDERS, + detect_static_provider_for_model, + ) + + for provider in ("anthropic", "xai"): + if provider in _SILENT_DEFAULT_PROVIDERS: + continue + result = detect_static_provider_for_model(provider, "openrouter") + assert result is not None + assert result[1] == _PROVIDER_MODELS[provider][0] class TestGatewayEmptyModelFallback: diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index e4b064ed947e..8eddceb70eaf 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -483,13 +483,314 @@ class TestParseReasoningEffort: """Guard against silently dropping a documented level. The docstring promises "minimal", "low", "medium", "high", "xhigh", - "max". If someone removes one from VALID_REASONING_EFFORTS without + "max", "ultra". If someone removes one from VALID_REASONING_EFFORTS without updating the docstring, this test will fail and force the call out. """ - documented = {"minimal", "low", "medium", "high", "xhigh", "max"} + documented = {"minimal", "low", "medium", "high", "xhigh", "max", "ultra"} assert documented.issubset(set(VALID_REASONING_EFFORTS)) +class TestResolvePerModelReasoningEffort: + """Tests for resolve_per_model_reasoning_effort() — spelling-tolerant + per-model override lookup from agent.reasoning_overrides dict. + + Contract: the override key the user writes in config.yaml should match + regardless of how downstream consumers normalize the model string. + normalize_model_for_provider() converts dots to dashes and + adds/strips provider prefixes. Our resolver tolerates these + variations so the user's intent ("this model always gets xhigh") + is honored no matter which code path feeds the model string. + """ + + def test_exact_match(self): + """Exact model string match returns the parsed override.""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "xhigh"} + result = resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) + assert result == {"enabled": True, "effort": "xhigh"} + + def test_none_when_no_matching_key(self): + """Model not in overrides returns None (caller falls back to global).""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "xhigh"} + assert resolve_per_model_reasoning_effort("gpt-5", overrides) is None + + def test_none_value_returns_disabled(self): + """Override set to 'none' returns {'enabled': False}.""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "none"} + result = resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) + assert result == {"enabled": False} + + def test_invalid_value_returns_none(self): + """Override with invalid effort falls back to None (global).""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "banana"} + assert resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) is None + + def test_none_or_empty_overrides_returns_none(self): + """None or empty overrides dict returns None.""" + from hermes_constants import resolve_per_model_reasoning_effort + assert resolve_per_model_reasoning_effort("claude-opus-4.5", None) is None + assert resolve_per_model_reasoning_effort("claude-opus-4.5", {}) is None + + def test_empty_model_returns_none(self): + """Empty model string returns None.""" + from hermes_constants import resolve_per_model_reasoning_effort + assert resolve_per_model_reasoning_effort("", {"gpt-5": "low"}) is None + + # --- Spelling tolerance layer --- + + def test_dots_to_dashes_variant(self): + """User wrote key with dots; input comes in normalized with dashes. + + normalize_model_for_provider converts claude-opus-4.5 → claude-opus-4-5 + for the anthropic provider. The user's override key should still match. + """ + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "xhigh"} + result = resolve_per_model_reasoning_effort("claude-opus-4-5", overrides) + assert result == {"enabled": True, "effort": "xhigh"} + + def test_dashes_to_dots_variant(self): + """User wrote key with dashes; input comes in with dots.""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4-5": "high"} + result = resolve_per_model_reasoning_effort("claude-opus.4.5", overrides) + assert result == {"enabled": True, "effort": "high"} + + def test_strip_provider_prefix(self): + """User wrote key WITH provider prefix; input comes in bare. + + E.g. user config: model.default: claude-opus-4.5 (no prefix), + but override key: anthropic/claude-opus-4.5. + """ + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"anthropic/claude-opus-4.5": "high"} + result = resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) + assert result == {"enabled": True, "effort": "high"} + + def test_prepend_provider_prefix(self): + """User wrote key bare; input comes in WITH provider prefix. + + E.g. user config: model.default: anthropic/claude-opus-4.5, + but override key: claude-opus-4.5 (no prefix). + """ + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "high"} + result = resolve_per_model_reasoning_effort("anthropic/claude-opus-4.5", overrides) + assert result == {"enabled": True, "effort": "high"} + + def test_aggregator_prefix_stripping(self): + """openrouter/anthropic/claude-opus-4.5 should match key anthropic/claude-opus-4.5. + + Aggregator providers (openrouter) prepend their own name, + creating a triple-prefix. The resolver strips the aggregator + layer to find the user's two-segment key. + """ + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"anthropic/claude-opus-4.5": "xhigh"} + result = resolve_per_model_reasoning_effort("openrouter/anthropic/claude-opus-4.5", overrides) + assert result == {"enabled": True, "effort": "xhigh"} + + def test_exact_match_wins_over_variant(self): + """Ambiguity resolution: exact match takes priority over a variant. + + If both 'claude-opus-4.5' (exact) and 'claude-opus-4-5' (dashes + variant) are keys, the exact input matches the exact key first. + """ + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "high", "claude-opus-4-5": "xhigh"} + result = resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) + assert result == {"enabled": True, "effort": "high"} + + def test_none_when_no_variant_matches(self): + """All variants exhausted without a match returns None.""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"gpt-5": "low"} + assert resolve_per_model_reasoning_effort("claude-opus-4.5", overrides) is None + + def test_all_dotted_input_matches_canonical_key(self): + """Regression: all-dotted input (claude-opus.4.5) must match + canonical key (claude-opus-4.5). + + This was a real bug found by delegate review: the old + all_dashed = model.replace('.', '-') collapsed version dots, + making the canonical form unreachable from all-dotted input. + """ + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"claude-opus-4.5": "xhigh"} + result = resolve_per_model_reasoning_effort("claude-opus.4.5", overrides) + assert result is not None + assert result["effort"] == "xhigh" + + def test_different_models_do_not_match(self): + """No false positives: gemini-2.0-flash must not match gemini-flash.""" + from hermes_constants import resolve_per_model_reasoning_effort + overrides = {"gemini-flash": "low"} + assert resolve_per_model_reasoning_effort("gemini-2.0-flash", overrides) is None + + +class TestResolveReasoningConfig: + """Tests for resolve_reasoning_config() — the single shared chokepoint + every surface (CLI, gateway, TUI, cron, /model switch, fallback) calls. + + Contract: per-model override > global agent.reasoning_effort; the raw + global value passes through uncoerced (YAML False = disabled); an + explicit model argument wins over the config's model.default. + """ + + def _cfg(self, effort: object = "medium", overrides=None, default_model="gpt-5"): + return { + "model": {"default": default_model}, + "agent": { + "reasoning_effort": effort, + "reasoning_overrides": overrides or {}, + }, + } + + def test_per_model_override_wins(self): + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(overrides={"claude-opus-4.5": "xhigh"}) + result = resolve_reasoning_config(cfg, "claude-opus-4.5") + assert result == {"enabled": True, "effort": "xhigh"} + + def test_global_fallback_when_no_override(self): + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(effort="low", overrides={"claude-opus-4.5": "xhigh"}) + assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": True, "effort": "low"} + + def test_explicit_model_wins_over_config_default(self): + """The session's effective model (e.g. after a session-only /model + switch) must be used for override lookup — NOT model.default.""" + from hermes_constants import resolve_reasoning_config + cfg = self._cfg( + effort="medium", + overrides={"gpt-5": "low", "claude-opus-4.5": "xhigh"}, + default_model="gpt-5", + ) + # Session switched to opus; its override must win over gpt-5's. + result = resolve_reasoning_config(cfg, "claude-opus-4.5") + assert result == {"enabled": True, "effort": "xhigh"} + + def test_empty_model_derives_from_config_default(self): + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(overrides={"gpt-5": "high"}, default_model="gpt-5") + assert resolve_reasoning_config(cfg) == {"enabled": True, "effort": "high"} + + def test_empty_model_derives_from_model_alias_key(self): + """model: {model: ...} alias shape (older configs) also resolves.""" + from hermes_constants import resolve_reasoning_config + cfg = { + "model": {"model": "gpt-5"}, + "agent": {"reasoning_effort": "medium", "reasoning_overrides": {"gpt-5": "high"}}, + } + assert resolve_reasoning_config(cfg) == {"enabled": True, "effort": "high"} + + def test_string_model_section(self): + """Top-level ``model: `` config shape (cron raw-YAML path).""" + from hermes_constants import resolve_reasoning_config + cfg = { + "model": "claude-opus-4.5", + "agent": {"reasoning_effort": "low", "reasoning_overrides": {"claude-opus-4.5": "xhigh"}}, + } + assert resolve_reasoning_config(cfg) == {"enabled": True, "effort": "xhigh"} + + def test_yaml_false_global_uncoerced(self): + """YAML boolean False must mean disabled — never coerced to ''.""" + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(effort=False) + assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": False} + + def test_yaml_false_not_shadowed_by_other_models_override(self): + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(effort=False, overrides={"claude-opus-4.5": "xhigh"}) + assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": False} + + def test_override_none_disables_for_model(self): + """Per-model override value 'none' disables thinking for that model.""" + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(effort="high", overrides={"gemini-flash": "none"}) + assert resolve_reasoning_config(cfg, "gemini-flash") == {"enabled": False} + + def test_unknown_global_returns_none(self): + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(effort="bogus-level") + assert resolve_reasoning_config(cfg, "gpt-5") is None + + def test_empty_config_returns_none(self): + from hermes_constants import resolve_reasoning_config + assert resolve_reasoning_config({}) is None + assert resolve_reasoning_config(None) is None + + def test_malformed_sections_tolerated(self): + """Non-dict agent/model sections must not raise.""" + from hermes_constants import resolve_reasoning_config + assert resolve_reasoning_config({"agent": "oops", "model": 42}) is None + assert resolve_reasoning_config({"agent": None, "model": None}) is None + assert resolve_reasoning_config({"agent": {"reasoning_overrides": "bad"}}) is None + + def test_invalid_override_value_falls_back_to_global(self): + """A junk override value for the matching model falls through to global.""" + from hermes_constants import resolve_reasoning_config + cfg = self._cfg(effort="medium", overrides={"gpt-5": "turbo-max"}) + assert resolve_reasoning_config(cfg, "gpt-5") == {"enabled": True, "effort": "medium"} + + +class TestReasoningOverridesDefaultConfig: + """Tests for the agent.reasoning_overrides default config key (Task 2).""" + + def test_default_config_has_reasoning_overrides_key(self): + """DEFAULT_CONFIG['agent'] contains 'reasoning_overrides' as an empty dict.""" + from hermes_cli.config import DEFAULT_CONFIG + assert "reasoning_overrides" in DEFAULT_CONFIG["agent"] + assert DEFAULT_CONFIG["agent"]["reasoning_overrides"] == {} + + def test_load_config_preserves_user_reasoning_overrides(self, tmp_path, monkeypatch): + """User-added reasoning_overrides are preserved through load_config().""" + import yaml + from hermes_cli.config import load_config, get_config_path + + user_config = { + "agent": { + "reasoning_overrides": { + "anthropic/claude-opus-4-5": "high", + "openrouter/anthropic/claude-sonnet-4-6": "low", + } + } + } + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.safe_dump(user_config)) + + # load_config() reads from get_config_path() — patch its global reference + monkeypatch.setitem( + load_config.__globals__, "get_config_path", lambda: config_path + ) + + loaded = load_config() + assert loaded["agent"]["reasoning_overrides"] == { + "anthropic/claude-opus-4-5": "high", + "openrouter/anthropic/claude-sonnet-4-6": "low", + } + + def test_spelling_tolerant_lookup_works_with_user_config(self): + """resolve_per_model_reasoning_effort works with user-added overrides.""" + from hermes_constants import resolve_per_model_reasoning_effort + # User config with one override, query uses different spelling + overrides = { + "anthropic/claude-opus-4.5": "xhigh", # user wrote with dots + } + # Lookup with different spelling (bare, dashes) — should still match + result = resolve_per_model_reasoning_effort("claude-opus-4-5", overrides) + assert result == {"enabled": True, "effort": "xhigh"} + + # Another override, bare key + overrides2 = {"gpt-5": "low"} + # Lookup with provider prefix — should match + result2 = resolve_per_model_reasoning_effort("openai/gpt-5", overrides2) + assert result2 == {"enabled": True, "effort": "low"} + + class TestSecureParentDir: """Tests for secure_parent_dir() — prevents chmod on / or top-level dirs.""" @@ -834,3 +1135,38 @@ class TestGetHermesDir: legacy.symlink_to(empty) result = get_hermes_dir("cache/audio", "audio_cache") assert result == tmp_path / "cache/audio" + + +class TestWslPathTranslation: + """Cross-boundary path translation for a Windows-host UI + WSL backend.""" + + def test_windows_drive_to_wsl_mount(self): + assert hermes_constants.windows_path_to_wsl(r"C:\Users\alex") == "/mnt/c/Users/alex" + assert hermes_constants.windows_path_to_wsl("C:/Users/alex") == "/mnt/c/Users/alex" + assert hermes_constants.windows_path_to_wsl("D:\\") == "/mnt/d/" + + def test_windows_drive_ignores_non_drive_paths(self): + assert hermes_constants.windows_path_to_wsl("/home/alex") is None + assert hermes_constants.windows_path_to_wsl("relative\\dir") is None + + def test_wsl_unc_to_posix_both_spellings(self): + assert hermes_constants.wsl_unc_path_to_posix(r"\\wsl.localhost\Ubuntu\home\alex") == "/home/alex" + assert hermes_constants.wsl_unc_path_to_posix(r"\\wsl$\Ubuntu\home\alex") == "/home/alex" + # Forward-slash spelling and distro root. + assert hermes_constants.wsl_unc_path_to_posix("//wsl.localhost/Debian/srv/app") == "/srv/app" + assert hermes_constants.wsl_unc_path_to_posix("\\\\wsl.localhost\\Ubuntu\\") == "/" + + def test_wsl_unc_ignores_non_unc_paths(self): + assert hermes_constants.wsl_unc_path_to_posix(r"C:\Users\alex") is None + assert hermes_constants.wsl_unc_path_to_posix("/home/alex") is None + + def test_translate_is_noop_off_wsl(self, monkeypatch): + monkeypatch.setattr(hermes_constants, "is_wsl", lambda: False) + assert hermes_constants.translate_cwd_for_wsl_backend(r"C:\Users\alex") == r"C:\Users\alex" + + def test_translate_maps_windows_and_unc_on_wsl(self, monkeypatch): + monkeypatch.setattr(hermes_constants, "is_wsl", lambda: True) + assert hermes_constants.translate_cwd_for_wsl_backend(r"C:\Users\alex") == "/mnt/c/Users/alex" + assert hermes_constants.translate_cwd_for_wsl_backend(r"\\wsl.localhost\Ubuntu\home\alex") == "/home/alex" + # Already-POSIX paths pass through untouched. + assert hermes_constants.translate_cwd_for_wsl_backend("/home/alex") == "/home/alex" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 4ad888442afc..eb6aaf279ea3 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -272,6 +272,43 @@ class TestSessionLifecycle: session = db.get_session("s1") assert session["model"] == "openai/gpt-5.4" + def test_first_accounted_fallback_replaces_requested_primary_route(self, db): + """First successful fallback usage must persist one coherent route pair.""" + db.create_session(session_id="s1", source="cli", model="gpt-5.6-sol") + + db.update_token_counts( + "s1", + input_tokens=10, + output_tokens=5, + model="glm-5.2", + billing_provider="custom:zai", + billing_base_url="https://api.z.ai/api/coding/paas/v4/", + api_call_count=1, + ) + + session = db.get_session("s1") + assert session["model"] == "glm-5.2" + assert session["billing_provider"] == "custom:zai" + assert session["billing_base_url"] == "https://api.z.ai/api/coding/paas/v4/" + assert session["api_call_count"] == 1 + + def test_accounted_primary_route_is_not_rewritten_by_later_fallback(self, db): + """A mixed-provider session keeps its first accounted route in the legacy row.""" + db.create_session(session_id="s1", source="cli", model="gpt-5.6-sol") + db.update_token_counts( + "s1", input_tokens=10, output_tokens=5, model="gpt-5.6-sol", + billing_provider="openai-codex", api_call_count=1, + ) + db.update_token_counts( + "s1", input_tokens=10, output_tokens=5, model="glm-5.2", + billing_provider="custom:zai", api_call_count=1, + ) + + session = db.get_session("s1") + assert session["model"] == "gpt-5.6-sol" + assert session["billing_provider"] == "openai-codex" + assert session["api_call_count"] == 2 + def test_update_token_counts_preserves_existing_model(self, db): db.create_session(session_id="s1", source="cli", model="anthropic/claude-opus-4.6") db.update_token_counts("s1", input_tokens=10, output_tokens=5, model="openai/gpt-5.4") @@ -351,6 +388,181 @@ class TestSessionLifecycle: assert sess["billing_provider"] == "openai" assert sess["billing_mode"] == "local" # preserved (COALESCE on None) + def test_per_model_usage_recorded_for_single_model(self, db): + """Each per-call delta lands in session_model_usage (#51607).""" + db.create_session(session_id="s1", source="cli") + db.update_token_counts("s1", input_tokens=200, output_tokens=100, + model="anthropic/claude-opus-4.8", + billing_provider="anthropic", api_call_count=1) + db.update_token_counts("s1", input_tokens=100, output_tokens=50, + model="anthropic/claude-opus-4.8", + billing_provider="anthropic", api_call_count=1) + + rows = db._conn.execute( + "SELECT model, billing_provider, api_call_count, input_tokens, " + "output_tokens FROM session_model_usage WHERE session_id = 's1'" + ).fetchall() + assert len(rows) == 1 + row = rows[0] + assert row["model"] == "anthropic/claude-opus-4.8" + assert row["billing_provider"] == "anthropic" + assert row["api_call_count"] == 2 + assert row["input_tokens"] == 300 + assert row["output_tokens"] == 150 + + def test_mid_session_switch_splits_per_model_usage(self, db): + """The headline #51607 case: tokens after a /model switch are + attributed to the new model, not the session's initial model. + + The ``sessions`` summary row still holds combined totals + the latest + model, but session_model_usage keeps an accurate per-model split. + """ + db.create_session(session_id="s1", source="cli", + model="deepseek/deepseek-v4-pro") + # Pre-switch calls on deepseek. + db.update_token_counts("s1", input_tokens=40_000, output_tokens=8_000, + model="deepseek/deepseek-v4-pro", + billing_provider="deepseek", api_call_count=2) + # User runs /model — the gateway persists the new model … + db.update_session_model("s1", "anthropic/claude-opus-4.8") + # … and subsequent per-call deltas carry the new model/provider. + db.update_token_counts("s1", input_tokens=50_000, output_tokens=4_000, + model="anthropic/claude-opus-4.8", + billing_provider="openrouter", api_call_count=3) + + rows = { + r["model"]: r + for r in db._conn.execute( + "SELECT model, billing_provider, input_tokens, output_tokens, " + "api_call_count FROM session_model_usage WHERE session_id = 's1'" + ).fetchall() + } + assert set(rows) == {"deepseek/deepseek-v4-pro", + "anthropic/claude-opus-4.8"} + assert rows["deepseek/deepseek-v4-pro"]["input_tokens"] == 40_000 + assert rows["deepseek/deepseek-v4-pro"]["api_call_count"] == 2 + assert rows["anthropic/claude-opus-4.8"]["input_tokens"] == 50_000 + assert rows["anthropic/claude-opus-4.8"]["billing_provider"] == "openrouter" + assert rows["anthropic/claude-opus-4.8"]["api_call_count"] == 3 + + # Summary row: latest model + combined totals (unchanged behaviour). + session = db.get_session("s1") + assert session["model"] == "anthropic/claude-opus-4.8" + assert session["input_tokens"] == 90_000 + assert session["output_tokens"] == 12_000 + + def test_per_model_usage_falls_back_to_session_model(self, db): + """When a call omits the model, attribute it to the session's + recorded model — matches the COALESCE-from-session summary behaviour + and keeps existing callers (which pass no model) working. + """ + db.create_session(session_id="s1", source="cli", + model="gpt-4o", ) + db.update_token_counts("s1", input_tokens=10, output_tokens=5) + + rows = db._conn.execute( + "SELECT model FROM session_model_usage WHERE session_id = 's1'" + ).fetchall() + assert len(rows) == 1 + assert rows[0]["model"] == "gpt-4o" + + def test_absolute_update_does_not_record_per_model(self, db): + """absolute=True overwrites the cumulative summary row (gateway path) + and must NOT add per-model rows — those are accumulated from the + per-call incremental path, so recording here would double-count. + """ + db.create_session(session_id="s1", source="cli", model="gpt-4o") + db.update_token_counts("s1", input_tokens=500, output_tokens=200, + model="gpt-4o", absolute=True) + + rows = db._conn.execute( + "SELECT COUNT(*) AS n FROM session_model_usage WHERE session_id = 's1'" + ).fetchone() + assert rows["n"] == 0 + + def test_per_model_usage_keeps_distinct_billing_routes(self, db): + """The same model through distinct billing routes must not collapse.""" + db.create_session(session_id="routes", source="cli", model="shared-model") + db.update_token_counts( + "routes", input_tokens=10, model="shared-model", + billing_provider="custom", billing_base_url="https://one.example/v1", + billing_mode="api_key", estimated_cost_usd=0.01, api_call_count=1, + ) + db.update_token_counts( + "routes", input_tokens=20, model="shared-model", + billing_provider="custom", billing_base_url="https://two.example/v1", + billing_mode="subscription_included", estimated_cost_usd=0.0, + cost_status="included", api_call_count=1, + ) + + rows = db._conn.execute( + "SELECT billing_base_url, billing_mode, input_tokens " + "FROM session_model_usage WHERE session_id = 'routes' " + "ORDER BY billing_base_url" + ).fetchall() + assert [(r["billing_base_url"], r["billing_mode"], r["input_tokens"]) + for r in rows] == [ + ("https://one.example/v1", "api_key", 10), + ("https://two.example/v1", "subscription_included", 20), + ] + + def test_metadata_only_update_does_not_replace_requested_route(self, db): + db.create_session(session_id="metadata", source="cli", model="primary") + db.update_token_counts( + "metadata", model="fallback", billing_provider="fallback-provider", + api_call_count=0, + ) + row = db.get_session("metadata") + assert row["model"] == "primary" + assert row["billing_provider"] is None + + def test_first_accounted_route_replaces_all_route_fields_atomically(self, db): + db.create_session(session_id="route", source="cli", model="primary") + db.update_session_billing_route( + "route", provider="primary-provider", + base_url="https://primary.example/v1", billing_mode="api_key", + ) + db.update_token_counts( + "route", model="fallback", billing_provider="fallback-provider", + billing_base_url=None, billing_mode=None, api_call_count=1, + ) + row = db.get_session("route") + assert row["model"] == "fallback" + assert row["billing_provider"] == "fallback-provider" + assert row["billing_base_url"] is None + assert row["billing_mode"] is None + + def test_v17_backfill_seeds_existing_session_usage(self, tmp_path): + """A DB upgraded from <17 seeds one usage row per historical session + from its aggregate totals, so insights read uniformly from the table. + """ + db_path = tmp_path / "legacy.db" + db = SessionDB(db_path=db_path) + db.create_session(session_id="legacy1", source="cli", model="gpt-4o") + db.update_token_counts("legacy1", input_tokens=1234, output_tokens=567, + model="gpt-4o", billing_provider="openai") + # Simulate a pre-v17 database: drop the per-model rows and roll the + # recorded schema version back so the backfill migration re-runs. + db._conn.execute("DELETE FROM session_model_usage") + db._conn.execute("UPDATE schema_version SET version = 16") + db._conn.commit() + db.close() + + # Reopen — _init_schema should backfill from the sessions aggregate. + db2 = SessionDB(db_path=db_path) + try: + rows = db2._conn.execute( + "SELECT model, billing_provider, input_tokens, output_tokens " + "FROM session_model_usage WHERE session_id = 'legacy1'" + ).fetchall() + assert len(rows) == 1 + assert rows[0]["model"] == "gpt-4o" + assert rows[0]["billing_provider"] == "openai" + assert rows[0]["input_tokens"] == 1234 + assert rows[0]["output_tokens"] == 567 + finally: + db2.close() + def test_parent_session(self, db): db.create_session(session_id="parent", source="cli") db.create_session(session_id="child", source="cli", parent_session_id="parent") @@ -934,6 +1146,19 @@ class TestMessageStorage: tool_msg = next(m for m in msgs if m["role"] == "tool") assert tool_msg["tool_name"] == "web_search" + def test_tool_effect_disposition_round_trips_through_session_db(self, db): + from agent.tool_dispatch_helpers import make_tool_result_message + + db.create_session(session_id="s1", source="cli") + db.replace_messages( + "s1", + [make_tool_result_message( + "write_file", "worker detached", "c1", effect_disposition="unknown" + )], + ) + + assert db.get_messages_as_conversation("s1")[0]["effect_disposition"] == "unknown" + def test_replace_messages_handles_multimodal_content(self, db): """`replace_messages` (used by /retry, /undo, /compress) must also handle list content without crashing.""" @@ -1881,6 +2106,18 @@ class TestDeleteAndExport: assert db.get_session("s1") is None assert db.message_count(session_id="s1") == 0 + def test_delete_session_cascades_per_model_usage(self, db): + db.create_session(session_id="usage", source="cli", model="gpt-5") + db.update_token_counts( + "usage", input_tokens=10, model="gpt-5", + billing_provider="openai", api_call_count=1, + ) + assert db.delete_session("usage") is True + count = db._conn.execute( + "SELECT COUNT(*) FROM session_model_usage WHERE session_id = 'usage'" + ).fetchone()[0] + assert count == 0 + def test_delete_nonexistent(self, db): assert db.delete_session("nope") is False @@ -1931,6 +2168,221 @@ class TestDeleteAndExport: assert len(exports) == 1 assert exports[0]["source"] == "cli" + def test_import_exported_session_round_trips(self, db, tmp_path): + db.create_session( + session_id="s1", + source="cli", + model="test-model", + model_config={"temperature": 0.2}, + user_id="user-1", + cwd="/workspace", + ) + db.set_session_title("s1", "Imported session") + db.update_session_cwd( + "s1", + "/workspace/project", + git_branch="feature/import", + git_repo_root="/workspace/project", + ) + db.append_message("s1", role="user", content="Hello", timestamp=10) + db.append_message( + "s1", + role="assistant", + content="Hi", + timestamp=11, + tool_calls=[{"id": "call-1", "function": {"name": "noop"}}], + reasoning_details=[{"type": "summary", "text": "short"}], + ) + db.end_session("s1", "complete") + + exported = db.export_session("s1") + exported["handoff_state"] = "active" + exported["handoff_platform"] = "telegram" + exported["handoff_error"] = "stale runtime state" + exported["rewind_count"] = 3 + target = SessionDB(db_path=tmp_path / "target_state.db") + try: + result = target.import_sessions([exported]) + assert result["ok"] is True + assert result["imported"] == 1 + assert result["skipped"] == 0 + + imported = target.get_session("s1") + assert imported["title"] == "Imported session" + assert imported["source"] == "cli" + assert imported["model"] == "test-model" + assert imported["cwd"] == "/workspace/project" + assert imported["git_branch"] == "feature/import" + assert imported["git_repo_root"] == "/workspace/project" + assert imported["message_count"] == 2 + assert imported["tool_call_count"] == 1 + assert imported["handoff_state"] is None + assert imported["handoff_platform"] is None + assert imported["handoff_error"] is None + assert imported["rewind_count"] == 0 + + messages = target.get_messages("s1") + assert [m["role"] for m in messages] == ["user", "assistant"] + assert messages[0]["content"] == "Hello" + assert messages[1]["tool_calls"][0]["id"] == "call-1" + + duplicate = target.import_sessions([exported]) + assert duplicate["imported"] == 0 + assert duplicate["skipped"] == 1 + assert duplicate["skipped_ids"] == ["s1"] + finally: + target.close() + + def test_import_sessions_restores_valid_parents_and_detaches_missing(self, db): + result = db.import_sessions( + [ + { + "id": "child", + "source": "cli", + "parent_session_id": "parent", + "messages": [], + }, + {"id": "parent", "source": "cli", "messages": []}, + { + "id": "orphan", + "source": "cli", + "parent_session_id": "missing", + "messages": [], + }, + ] + ) + + assert result["ok"] is True + assert result["imported"] == 3 + assert result["detached"] == 1 + assert db.get_session("child")["parent_session_id"] == "parent" + assert db.get_session("orphan")["parent_session_id"] is None + + def test_import_sessions_rejects_invalid_batch_atomically(self, db): + result = db.import_sessions( + [ + {"id": "valid", "source": "cli", "messages": []}, + {"source": "cli", "messages": []}, + ] + ) + + assert result["ok"] is False + assert result["imported"] == 0 + assert result["errors"] == [ + {"index": 1, "error": "session id is required"} + ] + assert db.get_session("valid") is None + + def test_import_sessions_detaches_cycle_and_lineage_still_terminates(self, db): + result = db.import_sessions( + [ + { + "id": "a", + "source": "cli", + "parent_session_id": "b", + "end_reason": "compression", + "messages": [], + }, + { + "id": "b", + "source": "cli", + "parent_session_id": "a", + "end_reason": "compression", + "messages": [], + }, + ] + ) + + assert result["ok"] is True + assert result["detached"] == 1 + assert db.get_session("a")["parent_session_id"] is None + assert db.get_session("b")["parent_session_id"] == "a" + assert db.get_compression_lineage("a") == ["a", "b"] + + def test_import_sessions_detaches_self_parent(self, db): + result = db.import_sessions( + [ + { + "id": "self", + "source": "cli", + "parent_session_id": "self", + "end_reason": "compression", + "messages": [], + } + ] + ) + + assert result["ok"] is True + assert result["detached"] == 1 + assert db.get_session("self")["parent_session_id"] is None + + def test_compression_lineage_terminates_for_preexisting_cycle(self, db): + db.create_session("a", "cli") + db.end_session("a", "compression") + db.create_session("b", "cli", parent_session_id="a") + db.end_session("b", "compression") + db._conn.execute("UPDATE sessions SET parent_session_id = ? WHERE id = ?", ("b", "a")) + db._conn.commit() + + lineage = db.get_compression_lineage("a") + assert set(lineage) == {"a", "b"} + assert len(lineage) == 2 + assert set(db.export_session_lineage("a")["lineage_session_ids"]) == {"a", "b"} + + @pytest.mark.parametrize( + ("payload", "error"), + [ + ( + {"id": "bad-json", "model_config": "{not-json", "messages": []}, + "model_config must be valid JSON", + ), + ( + {"id": "bad-text", "user_id": {"not": "text"}, "messages": []}, + "user_id must be a string", + ), + ( + {"id": "missing-role", "messages": [{"content": "x"}]}, + "messages[0].role must be a non-empty string", + ), + ( + {"id": "null-role", "messages": [{"role": None, "content": "x"}]}, + "messages[0].role must be a non-empty string", + ), + ], + ) + def test_import_sessions_rejects_invalid_metadata(self, db, payload, error): + result = db.import_sessions([payload]) + + assert result["ok"] is False + assert result["errors"] == [{"index": 0, "session_id": payload["id"], "error": error}] + assert db.get_session(payload["id"]) is None + + def test_import_sessions_rejects_oversized_payloads_atomically(self, db): + oversized = "x" * (SessionDB._IMPORT_MAX_SESSION_BYTES + 1) + result = db.import_sessions( + [{"id": "oversized", "messages": [{"role": "user", "content": oversized}]}] + ) + + assert result["ok"] is False + assert result["errors"][0]["error"] == "session exceeds the import size limit" + assert db.get_session("oversized") is None + + result = db.import_sessions( + [ + { + "id": "too-many-messages", + "messages": [ + {"role": "user", "content": "x"} + ] + * (SessionDB._IMPORT_MAX_MESSAGES_PER_SESSION + 1), + } + ] + ) + + assert result["ok"] is False + assert result["errors"][0]["error"] == "messages exceeds the per-session import limit" + assert db.get_session("too-many-messages") is None + # ========================================================================= # Prune @@ -4728,6 +5180,75 @@ class TestApplyWalProbe: assert not any("checkpoint_fullfsync" in sql for sql in conn.executed), ( "checkpoint_fullfsync must not be issued off macOS" ) + assert not any("synchronous=FULL" in sql for sql in conn.executed), ( + "synchronous=FULL must not be issued off macOS" + ) + + def test_macos_synchronous_full_enforced_fresh(self, tmp_path, monkeypatch): + """On Darwin, apply_wal_with_fallback enforces synchronous=FULL (issue #63531).""" + import sqlite3 + import hermes_state + from hermes_state import apply_wal_with_fallback + + class _TracingConn(sqlite3.Connection): + def __init__(self, *a, **kw): + super().__init__(*a, **kw) + self.executed = [] + + def execute(self, sql, params=()): + self.executed.append(sql) + return super().execute(sql, params) + + monkeypatch.setattr(hermes_state.sys, "platform", "darwin") + + db_path = tmp_path / "macos_fresh_sync.db" + conn = _TracingConn(str(db_path)) + try: + result = apply_wal_with_fallback(conn) + finally: + conn.close() + + assert result == "wal" + assert any("synchronous=FULL" in sql for sql in conn.executed), ( + "synchronous=FULL must be enforced on macOS" + ) + + def test_macos_synchronous_full_enforced_already_wal(self, tmp_path, monkeypatch): + """synchronous=FULL is enforced even when DB is already in WAL mode (issue #63531).""" + import sqlite3 + import hermes_state + from hermes_state import apply_wal_with_fallback + + class _TracingConn(sqlite3.Connection): + def __init__(self, *a, **kw): + super().__init__(*a, **kw) + self.executed = [] + + def execute(self, sql, params=()): + self.executed.append(sql) + return super().execute(sql, params) + + # Prime the file into WAL mode first (simulating an existing WAL DB). + db_path = tmp_path / "macos_wal_sync.db" + with sqlite3.connect(str(db_path)) as seed: + seed.execute("PRAGMA journal_mode=WAL") + + monkeypatch.setattr(hermes_state.sys, "platform", "darwin") + + conn = _TracingConn(str(db_path)) + try: + result = apply_wal_with_fallback(conn) + finally: + conn.close() + + assert result == "wal" + # The early-return path for existing WAL must also enforce synchronous=FULL. + assert any("synchronous=FULL" in sql for sql in conn.executed), ( + "synchronous=FULL must be enforced even on existing WAL DBs" + ) + assert not any("journal_mode=WAL" in sql for sql in conn.executed), ( + "set-pragma must not run when already in WAL mode" + ) def test_apply_wal_concurrent_connects_no_eio(self, tmp_path): """20 threads calling connect() on the same DB must not see disk I/O error.""" @@ -5106,6 +5627,34 @@ def test_gateway_session_peer_round_trip_and_recovery(db): assert recovered["id"] == "gw-session" +def test_gateway_session_recovery_reopens_ws_orphan_reap_rows(db): + """Rows wrongly ended by the TUI ws-orphan reaper must be recoverable (#63207).""" + db.create_session( + "reaped-gw-session", + "telegram", + user_id="user-1", + session_key="agent:main:telegram:dm:chat-1", + chat_id="chat-1", + chat_type="dm", + ) + db.append_message("reaped-gw-session", "user", "hello") + db.end_session("reaped-gw-session", "ws_orphan_reap") + + recovered = db.find_latest_gateway_session_for_peer( + source="telegram", + user_id="user-1", + session_key="agent:main:telegram:dm:chat-1", + chat_id="chat-1", + chat_type="dm", + ) + assert recovered["id"] == "reaped-gw-session" + + db.reopen_session("reaped-gw-session") + row = db.get_session("reaped-gw-session") + assert row["ended_at"] is None + assert row["end_reason"] is None + + def test_gateway_session_recovery_reopens_legacy_agent_close_rows(db): db.create_session( "closed-gw-session", @@ -5339,6 +5888,14 @@ def test_expired_compression_failure_cooldown_is_ignored(db): assert db.get_compression_failure_cooldown("s1") is None +def test_compression_fallback_streak_round_trips(db): + db.create_session("s1", "cli") + + assert db.get_compression_fallback_streak("s1") == 0 + db.set_compression_fallback_streak("s1", 2) + assert db.get_compression_fallback_streak("s1") == 2 + + def test_refresh_compression_lock_requires_holder_and_preserves_reclaimability(db, monkeypatch): db.create_session("s1", "cli") @@ -5362,3 +5919,154 @@ def test_refresh_compression_lock_requires_holder_and_preserves_reclaimability(d monkeypatch.setattr(hermes_state.time, "time", lambda: 1016.0) assert db.try_acquire_compression_lock("s1", "holder-b", ttl_seconds=10.0) is True + + +# ========================================================================= +# compact_rows — lightweight column projection (issue #47414) +# ========================================================================= + +class TestCompactRows: + """list_sessions_rich and _get_session_rich_row with compact_rows=True + must omit system_prompt but return all other metadata fields.""" + + def _create(self, db, sid, *, system_prompt="big blob " * 500): + db.create_session(session_id=sid, source="cli", model="m") + db.update_system_prompt(sid, system_prompt) + return sid + + def test_compact_rows_omits_system_prompt(self, db): + self._create(db, "s1") + rows = db.list_sessions_rich(compact_rows=True) + assert len(rows) == 1 + assert "system_prompt" not in rows[0] + + def test_full_rows_include_system_prompt(self, db): + self._create(db, "s1", system_prompt="keep me") + rows = db.list_sessions_rich(compact_rows=False) + assert rows[0]["system_prompt"] == "keep me" + + def test_compact_rows_preserves_metadata_fields(self, db): + self._create(db, "s1") + rows = db.list_sessions_rich(compact_rows=True) + row = rows[0] + for field in ("id", "source", "model", "started_at", "message_count", + "input_tokens", "output_tokens", "title", "cwd", + "archived", "preview", "last_active"): + assert field in row, f"missing field: {field}" + + def test_compact_rows_order_by_last_active(self, db): + """compact_rows=True also works with the CTE / order_by_last_active path.""" + self._create(db, "s1") + self._create(db, "s2") + rows = db.list_sessions_rich(compact_rows=True, order_by_last_active=True) + assert len(rows) == 2 + assert all("system_prompt" not in r for r in rows) + + def test_get_session_rich_row_compact_omits_system_prompt(self, db): + self._create(db, "s1", system_prompt="should be gone") + row = db._get_session_rich_row("s1", compact_rows=True) + assert row is not None + assert "system_prompt" not in row + assert row["id"] == "s1" + + def test_get_session_rich_row_full_includes_system_prompt(self, db): + self._create(db, "s1", system_prompt="stay") + row = db._get_session_rich_row("s1", compact_rows=False) + assert row["system_prompt"] == "stay" + + def test_compact_rows_default_is_false(self, db): + """Default behaviour (compact_rows not passed) is unchanged — full rows.""" + self._create(db, "s1", system_prompt="present") + rows = db.list_sessions_rich() + assert "system_prompt" in rows[0] + + def test_compact_projection_tracks_schema(self, db): + """Behavior contract: compact rows carry EVERY sessions column except + the excluded blob — including gateway/desktop fields (git_branch, + session_key) and any column added later via declarative + reconciliation. Guards against a hardcoded column list going stale.""" + self._create(db, "s1") + live_cols = { + row[1] for row in db._conn.execute("PRAGMA table_info(sessions)") + } + row = db.list_sessions_rich(compact_rows=True)[0] + # Hardcode the one sanctioned exclusion: if the excluded set ever + # widens (or the projection silently drops a column), this fails and + # forces a conscious review of what list consumers lose. + missing = live_cols - set(row) - {"system_prompt"} + assert not missing, f"compact projection lost schema columns: {missing}" + assert "system_prompt" not in row + + def test_compact_rows_tip_projection_omits_system_prompt(self, db): + """Compression-tip projection must not reintroduce the blob: the + merged tip row is fetched with the same compact_rows flag (salvage + follow-up for #47437).""" + import time as _time + t0 = _time.time() - 3600 + db.create_session("root", "cli") + db.update_system_prompt("root", "root blob " * 200) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root")) + db._conn.execute( + "UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?", + (t0 + 100, "root"), + ) + db.create_session("tip", "cli", parent_session_id="root") + db.update_system_prompt("tip", "tip blob " * 200) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 200, "tip")) + db._conn.commit() + + rows = db.list_sessions_rich(compact_rows=True) + projected = [r for r in rows if r.get("_lineage_root_id") == "root"] + assert projected, "compression root should be projected to its tip" + assert all("system_prompt" not in r for r in rows) + + +# ========================================================================= +# get_messages pagination (salvage follow-up for #60347) +# ========================================================================= + +class TestGetMessagesPagination: + """get_messages(limit=, offset=) pages in insertion order; the default + (limit=None) returns the full transcript unchanged.""" + + def _seed(self, db, n=10): + db.create_session(session_id="s1", source="cli") + for i in range(n): + db.append_message("s1", "user" if i % 2 == 0 else "assistant", f"msg-{i}") + + def test_default_returns_all_messages(self, db): + self._seed(db) + messages = db.get_messages("s1") + assert [m["content"] for m in messages] == [f"msg-{i}" for i in range(10)] + + def test_limit_pages_in_insertion_order(self, db): + self._seed(db) + page1 = db.get_messages("s1", limit=4, offset=0) + page2 = db.get_messages("s1", limit=4, offset=4) + page3 = db.get_messages("s1", limit=4, offset=8) + assert [m["content"] for m in page1] == ["msg-0", "msg-1", "msg-2", "msg-3"] + assert [m["content"] for m in page2] == ["msg-4", "msg-5", "msg-6", "msg-7"] + assert [m["content"] for m in page3] == ["msg-8", "msg-9"] + + def test_offset_past_end_returns_empty(self, db): + self._seed(db, n=3) + assert db.get_messages("s1", limit=5, offset=10) == [] + + def test_pagination_respects_active_flag(self, db): + """Soft-deleted (inactive) rows must not consume page slots.""" + self._seed(db, n=6) + # Soft-delete the first two rows the way rewind does. + db._conn.execute( + "UPDATE messages SET active = 0 WHERE session_id = 's1' " + "AND id IN (SELECT id FROM messages WHERE session_id = 's1' ORDER BY id LIMIT 2)" + ) + db._conn.commit() + page = db.get_messages("s1", limit=3, offset=0) + assert [m["content"] for m in page] == ["msg-2", "msg-3", "msg-4"] + + def test_offset_without_limit_pages(self, db): + """offset alone must not be silently ignored (review finding): + SQLite needs LIMIT for OFFSET, emitted as LIMIT -1.""" + self._seed(db, n=5) + rows = db.get_messages("s1", offset=3) + assert [m["content"] for m in rows] == ["msg-3", "msg-4"] diff --git a/tests/test_package_json_lazy_deps.py b/tests/test_package_json_lazy_deps.py deleted file mode 100644 index 0e2456dba2a0..000000000000 --- a/tests/test_package_json_lazy_deps.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Invariants for what is eager vs lazy in the root ``package.json``. - -The root ``package.json`` is installed by ``hermes update`` on every user, -including users who never opted into a given browser backend. Anything -listed in ``dependencies`` therefore runs its npm postinstall script for -everyone — including binary-fetching backends, on every update. - -The contract: - -* ``agent-browser`` IS eager. It is the default Chromium-driving backend - used whenever the agent makes a browser call without a cloud provider - configured, so it must already be installed before any session starts. - Its postinstall is also small. - -* ``@askjo/camofox-browser`` is NOT eager. It is an explicit opt-in - alternative browser backend, selected by the user via - ``hermes tools`` → Browser Automation → Camofox, and only used at - runtime when ``CAMOFOX_URL`` is set. Its postinstall fetches a ~300MB - Firefox-fork binary, which silently blocked ``hermes update`` for - multi-minute stretches on slow / network-restricted connections - (notably users in China running through a VPN). The package is - installed on demand by ``tools_config.py`` ``post_setup_key == - "camofox"`` when the user actually selects Camofox. - -If a future PR re-adds Camofox (or any other binary-postinstall package) -to root ``dependencies``, this test fails — read the lazy-install -guidance in the ``hermes-agent-dev`` skill before changing the -expectations. -""" - -from __future__ import annotations - -import json -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parent.parent - - -def _root_package_json() -> dict: - with (REPO_ROOT / "package.json").open("r", encoding="utf-8") as fh: - return json.load(fh) - - -def test_camofox_is_not_in_root_dependencies() -> None: - """Camofox must be opt-in, installed lazily by its post_setup handler.""" - deps = _root_package_json().get("dependencies", {}) - assert "@askjo/camofox-browser" not in deps, ( - "Camofox is a ~300MB binary-postinstall backend that must stay " - "out of root package.json dependencies. It belongs in the " - "Camofox post_setup handler in hermes_cli/tools_config.py so it " - "only installs when the user explicitly selects Camofox via " - "`hermes tools` → Browser Automation → Camofox." - ) - - -def test_agent_browser_stays_eager() -> None: - """agent-browser is the default backend; it must remain eager.""" - deps = _root_package_json().get("dependencies", {}) - assert "agent-browser" in deps, ( - "agent-browser is the default browser-tool backend used by every " - "session that doesn't have a cloud browser provider configured. " - "It must stay in root package.json dependencies so it is present " - "after `hermes setup` / `hermes update` without an explicit " - "post_setup step." - ) - - -def test_root_lockfile_has_no_camofox_entries() -> None: - """Regenerated lockfiles should not contain Camofox tree entries.""" - lock_path = REPO_ROOT / "package-lock.json" - if not lock_path.exists(): - # Some CI matrix shards skip lockfile materialization. - return - text = lock_path.read_text(encoding="utf-8") - assert "@askjo/camofox-browser" not in text, ( - "package-lock.json still references @askjo/camofox-browser. " - "Regenerate the lockfile after removing the dep: " - "`rm package-lock.json && npm install --package-lock-only " - "--ignore-scripts --no-fund --no-audit`." - ) - assert "camoufox-js" not in text, ( - "package-lock.json still references camoufox-js (transitive of " - "@askjo/camofox-browser). Regenerate the lockfile." - ) diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index f2f4887d6091..8c0836e9059e 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -222,10 +222,10 @@ def test_feishu_extra_includes_qrcode_for_qr_login(): assert any(dep.startswith("qrcode") for dep in feishu_extra) -def test_nemo_relay_extra_uses_official_0_3_distribution(): +def test_nemo_relay_extra_uses_supported_official_distribution_range(): optional_dependencies = _load_optional_dependencies() - assert optional_dependencies["nemo-relay"] == ["nemo-relay==0.3"] + assert optional_dependencies["nemo-relay"] == ["nemo-relay>=0.5,<1.0"] assert not any( spec == "hermes-agent[nemo-relay]" for spec in optional_dependencies["all"] diff --git a/tests/test_session_workspace_binding.py b/tests/test_session_workspace_binding.py new file mode 100644 index 000000000000..36e7f2ec3db9 --- /dev/null +++ b/tests/test_session_workspace_binding.py @@ -0,0 +1,38 @@ +"""Session <-> workspace grouping key (hermes_state.workspace_key). + +The key is what `hermes sessions list --workspace` groups/filters on. It is a +coarse workspace identity derived from fields already recorded on sessions +(git_repo_root, cwd) — no git shelling, no new columns. Branch is deliberately +NOT part of the key. +""" + +from hermes_state import workspace_key + + +def test_repo_root_is_the_key_when_known(): + row = {"git_repo_root": "/www/app", "cwd": "/www/app/src", "git_branch": "feat"} + assert workspace_key(row) == "/www/app" + + +def test_falls_back_to_cwd_for_non_git_sessions(): + assert workspace_key({"cwd": "/work/notes"}) == "/work/notes" + assert workspace_key({"git_repo_root": "", "cwd": "/work/notes"}) == "/work/notes" + + +def test_none_when_unbound(): + assert workspace_key({}) is None + assert workspace_key({"cwd": "", "git_repo_root": ""}) is None + assert workspace_key({"cwd": " "}) is None + + +def test_branch_does_not_affect_the_key(): + # Two sessions on the same repo, different branches, group together. + a = {"git_repo_root": "/www/app", "git_branch": "main"} + b = {"git_repo_root": "/www/app", "git_branch": "feature-x"} + assert workspace_key(a) == workspace_key(b) == "/www/app" + + +def test_repo_root_wins_over_a_differing_cwd(): + # A worktree/subdir session still groups under its repo root, not its cwd. + row = {"git_repo_root": "/www/app", "cwd": "/www/app/.worktrees/x"} + assert workspace_key(row) == "/www/app" diff --git a/tests/test_telegram_polling_progress_ptb.py b/tests/test_telegram_polling_progress_ptb.py new file mode 100644 index 000000000000..d91419d506c1 --- /dev/null +++ b/tests/test_telegram_polling_progress_ptb.py @@ -0,0 +1,362 @@ +"""Integration coverage for polling progress against the installed PTB runtime.""" + +import asyncio + +import pytest +pytest.importorskip("telegram", reason="python-telegram-bot not installed") +from telegram.error import Conflict, TelegramError +from telegram.request import BaseRequest + +from gateway.config import PlatformConfig +from plugins.platforms.telegram import adapter as tg_adapter +from plugins.platforms.telegram.adapter import TelegramAdapter + + +class _GeneralRequest(BaseRequest): + @property + def read_timeout(self): + return 10 + + async def initialize(self): + return None + + async def shutdown(self): + return None + + async def do_request(self, url, method, request_data=None, **_kwargs): + if url.endswith("/getMe"): + return ( + 200, + b'{"ok":true,"result":{"id":1,"is_bot":true,' + b'"first_name":"Test","username":"test_bot"}}', + ) + return 200, b'{"ok":true,"result":true}' + + +class _GetUpdatesRequest(BaseRequest): + def __init__(self): + self.initial_conflict_sent = False + self.replacement_enabled = False + self.replacement_progress_sent = False + self.cleanup_calls = 0 + self.block = asyncio.Event() + + @property + def read_timeout(self): + return 10 + + async def initialize(self): + return None + + async def shutdown(self): + return None + + async def do_request(self, url, method, request_data=None, **_kwargs): + parameters = request_data.parameters if request_data is not None else {} + timeout = parameters.get("timeout") + timeout_seconds = ( + timeout.total_seconds() if hasattr(timeout, "total_seconds") else timeout + ) + if timeout_seconds == 0: + self.cleanup_calls += 1 + return 200, b'{"ok":true,"result":[]}' + if not self.initial_conflict_sent: + self.initial_conflict_sent = True + return ( + 409, + b'{"ok":false,"error_code":409,' + b'"description":"Conflict: another getUpdates request"}', + ) + if self.replacement_enabled and not self.replacement_progress_sent: + self.replacement_progress_sent = True + return 200, b'{"ok":true,"result":[]}' + await self.block.wait() + return 200, b'{"ok":true,"result":[]}' + + +class _EnvelopeRequest(BaseRequest): + def __init__(self, payload): + self.payload = payload + + @property + def read_timeout(self): + return 10 + + async def initialize(self): + return None + + async def shutdown(self): + return None + + async def do_request(self, url, method, request_data=None, **_kwargs): + return 200, self.payload + + +class _SlottedEnvelopeRequest(BaseRequest): + """A getUpdates request with no instance ``__dict__``. + + Reproduces PTB's real HTTPXRequest shape on Python 3.13, where every + class in the MRO defines ``__slots__`` and instances therefore reject an + instance-attribute ``do_request`` monkey-patch as "read-only" (#64482). + ``__slots__`` names the payload so the double needs no ``__dict__``. + """ + + __slots__ = ("_payload",) + + def __init__(self, payload): + self._payload = payload + + @property + def read_timeout(self): + return 10 + + async def initialize(self): + return None + + async def shutdown(self): + return None + + async def do_request(self, url, method, request_data=None, **_kwargs): + return 200, self._payload + + +async def _cancel_task(task): + if task is None or task.done(): + return + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_real_base_request_invalid_200_body_cannot_record_progress(): + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token")) + generation, progress = adapter._begin_polling_generation() + adapter._polling_network_error_count = 4 + adapter._polling_conflict_count = 3 + request = adapter._instrument_polling_request(_EnvelopeRequest(b"not-json")) + context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation) + + try: + with pytest.raises(TelegramError, match="Invalid server response"): + await request.post("https://api.telegram.org/bot-token/getUpdates") + finally: + tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token) + + assert not progress.is_set() + assert adapter._polling_network_error_count == 4 + assert adapter._polling_conflict_count == 3 + assert adapter._send_path_degraded is True + + +@pytest.mark.asyncio +async def test_real_base_request_bom_rejected_by_ptb_cannot_record_progress(): + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token")) + generation, progress = adapter._begin_polling_generation() + adapter._polling_network_error_count = 4 + adapter._polling_conflict_count = 3 + request = adapter._instrument_polling_request( + _EnvelopeRequest(b'\xef\xbb\xbf{"ok":true,"result":[]}') + ) + context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation) + + try: + with pytest.raises(TelegramError, match="Invalid server response"): + await request.post("https://api.telegram.org/bot-token/getUpdates") + finally: + tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token) + + assert not progress.is_set() + assert adapter._polling_network_error_count == 4 + assert adapter._polling_conflict_count == 3 + assert adapter._send_path_degraded is True + + +@pytest.mark.asyncio +async def test_real_base_request_ptb_replacement_decode_records_progress(): + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token")) + generation, progress = adapter._begin_polling_generation() + adapter._polling_network_error_count = 4 + adapter._polling_conflict_count = 3 + request = adapter._instrument_polling_request( + _EnvelopeRequest(b'{"ok":true,"result":[],"note":"\xff"}') + ) + context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation) + + try: + result = await request.post("https://api.telegram.org/bot-token/getUpdates") + finally: + tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token) + + assert result == [] + assert progress.is_set() + assert adapter._polling_network_error_count == 0 + assert adapter._polling_conflict_count == 0 + assert adapter._send_path_degraded is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("payload", "missing_result"), + [ + (b'{"ok":false,"result":[]}', False), + (b'{"ok":true}', True), + ], +) +async def test_real_base_request_unsuccessful_200_envelope_cannot_record_progress( + payload, missing_result +): + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token")) + generation, progress = adapter._begin_polling_generation() + adapter._polling_network_error_count = 4 + adapter._polling_conflict_count = 3 + request = adapter._instrument_polling_request(_EnvelopeRequest(payload)) + context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation) + + try: + if missing_result: + with pytest.raises(KeyError, match="result"): + await request.post("https://api.telegram.org/bot-token/getUpdates") + else: + assert await request.post( + "https://api.telegram.org/bot-token/getUpdates" + ) == [] + finally: + tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token) + + assert not progress.is_set() + assert adapter._polling_network_error_count == 4 + assert adapter._polling_conflict_count == 3 + assert adapter._send_path_degraded is True + + +@pytest.mark.asyncio +async def test_real_base_request_valid_success_envelope_records_progress(): + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token")) + generation, progress = adapter._begin_polling_generation() + adapter._polling_network_error_count = 4 + adapter._polling_conflict_count = 3 + request = adapter._instrument_polling_request( + _EnvelopeRequest(b'{"ok":true,"result":[]}') + ) + context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation) + + try: + result = await request.post( + "https://api.telegram.org/bot-token/getUpdates" + ) + finally: + tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token) + + assert result == [] + assert progress.is_set() + assert adapter._polling_network_error_count == 0 + assert adapter._polling_conflict_count == 0 + assert adapter._send_path_degraded is False + + +@pytest.mark.asyncio +async def test_slotted_request_instrumented_without_read_only_error(): + """Instrumenting a __slots__ request must not raise, and must still record. + + Regression for #64482: PTB's HTTPXRequest carries no instance ``__dict__`` + on Python 3.13, so the old ``request.do_request = wrapper`` monkey-patch + raised ``AttributeError: ... 'do_request' is read-only`` and broke every + Telegram connect. The subclass re-tag must instrument such an instance and + still observe getUpdates progress. + """ + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token")) + generation, progress = adapter._begin_polling_generation() + adapter._polling_network_error_count = 4 + adapter._polling_conflict_count = 3 + + request = _SlottedEnvelopeRequest(b'{"ok":true,"result":[]}') + # Guard the premise where the runtime actually enforces it: PTB's request + # MRO is only fully slotted on Python 3.13+ (contextlib's + # AbstractAsyncContextManager gained ``__slots__ = ()`` there). On older + # runtimes the instance still carries a ``__dict__`` and the legacy + # monkey-patch is accepted, so the read-only premise cannot be asserted. + if not hasattr(request, "__dict__"): + with pytest.raises(AttributeError, match="read-only"): + request.do_request = lambda *a, **k: None + + instrumented = adapter._instrument_polling_request(request) + context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation) + try: + result = await instrumented.post( + "https://api.telegram.org/bot-token/getUpdates" + ) + finally: + tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token) + + assert result == [] + assert progress.is_set() + assert adapter._polling_network_error_count == 0 + assert adapter._polling_conflict_count == 0 + assert adapter._send_path_degraded is False + + +@pytest.mark.asyncio +async def test_real_ptb_stop_cleanup_cannot_heal_recovery_generation(): + assert tg_adapter.TELEGRAM_AVAILABLE is True + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token")) + polling_request = _GetUpdatesRequest() + app = ( + tg_adapter.Application.builder() + .token("123456:test-token") + .request(_GeneralRequest()) + .get_updates_request(adapter._instrument_polling_request(polling_request)) + .build() + ) + adapter._app = app + adapter._polling_network_error_count = 4 + adapter._polling_conflict_count = 3 + callback_called = asyncio.Event() + recovery_task = None + + async def stop_for_recovery(): + await app.updater.stop() + + def schedule_recovery(error): + nonlocal recovery_task + assert isinstance(error, Conflict) + recovery_task = asyncio.create_task(stop_for_recovery()) + callback_called.set() + + await app.initialize() + try: + await adapter._start_polling_once( + app, + drop_pending_updates=False, + error_callback=schedule_recovery, + ) + generation = adapter._polling_generation + progress = adapter._polling_progress_event + await asyncio.wait_for(callback_called.wait(), timeout=2) + await asyncio.wait_for(recovery_task, timeout=3) + + assert polling_request.cleanup_calls == 1 + assert not progress.is_set() + assert adapter._polling_network_error_count == 4 + assert adapter._polling_conflict_count == 3 + assert adapter._send_path_degraded is True + + polling_request.replacement_enabled = True + await adapter._start_polling_once( + app, + drop_pending_updates=False, + error_callback=schedule_recovery, + ) + replacement_generation = adapter._polling_generation + replacement_progress = adapter._polling_progress_event + await asyncio.wait_for(replacement_progress.wait(), timeout=2) + + assert replacement_generation == generation + 1 + assert adapter._polling_network_error_count == 0 + assert adapter._polling_conflict_count == 0 + assert adapter._send_path_degraded is False + finally: + polling_request.block.set() + if app.updater.running: + await app.updater.stop() + await _cancel_task(adapter._polling_progress_verifier_task) + await app.shutdown() diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index cc6ff4f5b723..3b591156e289 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -424,6 +424,43 @@ def test_tui_verbose_tool_events_omit_details_when_redaction_fails(monkeypatch): assert "result_text" not in events[1][2] +def test_tui_tool_output_risk_event_exposes_metadata_without_raw_output(monkeypatch): + events: list[tuple[str, str, dict]] = [] + monkeypatch.setattr( + server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload)) + ) + monkeypatch.setitem( + server._sessions, + "risk-test", + {"tool_progress_mode": "all"}, + ) + + server._on_tool_progress( + "risk-test", + "tool.output_risk", + "web_extract", + tool_call_id="tool-1", + risk_metadata={ + "risk": "high", + "findings": ["prompt_injection"], + "redacted": False, + }, + ) + + assert events == [( + "tool.output_risk", + "risk-test", + { + "tool_id": "tool-1", + "name": "web_extract", + "risk": "high", + "findings": ["prompt_injection"], + "redacted": False, + }, + )] + assert "result" not in events[0][2] + + def test_dispatch_rejects_non_object_request(): resp = server.dispatch([]) @@ -2740,6 +2777,95 @@ def test_config_set_yolo_global_scope_writes_approvals_mode(tmp_path, monkeypatc assert yaml.safe_load(cfg_path.read_text())["approvals"]["mode"] == "manual" +def test_config_get_approval_mode_uses_smart_default_when_key_is_missing( + tmp_path, monkeypatch +): + import yaml + + monkeypatch.setattr(server, "_hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"approvals": {"timeout": 15}}) + ) + + response = server.handle_request( + {"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}} + ) + assert response["result"]["value"] == "smart" + + +def test_config_get_approval_mode_fails_safe_to_manual_for_invalid_explicit_value( + tmp_path, monkeypatch +): + import yaml + + monkeypatch.setattr(server, "_hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"approvals": {"mode": "sometimes"}}) + ) + + response = server.handle_request( + {"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}} + ) + assert response["result"]["value"] == "manual" + + +def test_config_get_approval_mode_normalizes_yaml_off(tmp_path, monkeypatch): + import yaml + + monkeypatch.setattr(server, "_hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"approvals": {"mode": False}}) + ) + + response = server.handle_request( + {"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}} + ) + assert response["result"]["value"] == "off" + + +def test_config_set_approval_mode_persists_three_way_value_and_emits_live_status( + tmp_path, monkeypatch +): + import yaml + + monkeypatch.setattr(server, "_hermes_home", tmp_path) + emitted = [] + monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args)) + server._sessions["sid"] = {"agent": object(), "session_key": "profile-session"} + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"key": "approvals.mode", "value": "manual"}, + } + ) + finally: + server._sessions.clear() + + assert resp["result"] == {"key": "approvals.mode", "value": "manual"} + assert yaml.safe_load((tmp_path / "config.yaml").read_text())["approvals"]["mode"] == "manual" + assert emitted and emitted[0][0:2] == ("session.info", "sid") + assert emitted[0][2]["approval_mode"] == "manual" + + +def test_desktop_contract_includes_approval_mode_rpc(): + assert server.DESKTOP_BACKEND_CONTRACT >= 3 + + +def test_config_set_approval_mode_rejects_unknown_value(): + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"key": "approvals.mode", "value": "sometimes"}, + } + ) + + assert resp["error"]["code"] == 4002 + + def test_config_set_yolo_global_scope_honors_explicit_value(tmp_path, monkeypatch): """An explicit value pins global approvals.mode regardless of prior state.""" import yaml @@ -4967,6 +5093,51 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch): server._sessions.pop("sid", None) +def test_prompt_submit_sanitizes_bracketed_paste_before_agent(monkeypatch): + """prompt.submit must sanitize corrupted user text before run_conversation.""" + captured: dict[str, str] = {} + + class _Agent: + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): + captured["prompt"] = prompt + return { + "final_response": "ok", + "messages": [{"role": "assistant", "content": "ok"}], + } + + class _ImmediateThread: + def __init__(self, target=None, daemon=None, **kw): + self._target = target + + def start(self): + self._target() + + corrupted = "hello[" + "~[[e" * 8 + server._sessions["sid"] = _session(agent=_Agent()) + try: + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_get_usage", lambda _a: {}) + monkeypatch.setattr(server, "render_message", lambda _t, _c: "") + monkeypatch.setattr(server, "_emit", lambda *a: None) + monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None) + monkeypatch.setattr(server, "_ensure_session_db_row", lambda *a, **k: None) + monkeypatch.setattr(server, "_persist_branch_seed", lambda *a, **k: None) + + resp = server.handle_request( + { + "id": "1", + "method": "prompt.submit", + "params": {"session_id": "sid", "text": corrupted}, + } + ) + assert resp.get("result"), f"got error: {resp.get('error')}" + assert captured["prompt"] == "hello" + finally: + server._sessions.pop("sid", None) + + def test_prompt_submit_history_version_match_persists_normally(monkeypatch): """Regression guard: the backstop does not affect the happy path.""" @@ -6590,7 +6761,7 @@ def test_session_most_recent_returns_first_non_denied(monkeypatch): """Drops `tool` rows like session.list does, returns the first hit.""" class _DB: - def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False): + def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False, compact_rows=False): return [ {"id": "tool-1", "source": "tool", "title": "noise", "started_at": 100}, {"id": "tui-1", "source": "tui", "title": "real", "started_at": 99}, @@ -6609,7 +6780,7 @@ def test_session_most_recent_returns_first_non_denied(monkeypatch): def test_session_most_recent_returns_null_when_only_tool_rows(monkeypatch): class _DB: - def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False): + def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False, compact_rows=False): return [{"id": "tool-1", "source": "tool", "started_at": 1}] monkeypatch.setattr(server, "_get_db", lambda: _DB()) @@ -6627,7 +6798,7 @@ def test_session_most_recent_folds_db_exception_into_null_result(monkeypatch): 'no answer' (Copilot review on #17130).""" class _BrokenDB: - def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False): + def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False, compact_rows=False): raise RuntimeError("db locked") monkeypatch.setattr(server, "_get_db", lambda: _BrokenDB()) @@ -7470,7 +7641,7 @@ def _setup_make_agent_mocks(monkeypatch, cfg): }, ) monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "off") - monkeypatch.setattr(server, "_load_reasoning_config", lambda: None) + monkeypatch.setattr(server, "_load_reasoning_config", lambda model="": None) monkeypatch.setattr(server, "_load_service_tier", lambda: None) monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: None) monkeypatch.setattr(server, "_get_db", lambda: None) diff --git a/tests/test_wal_checkpoint_strategy.py b/tests/test_wal_checkpoint_strategy.py new file mode 100644 index 000000000000..880aa4b5cc2d --- /dev/null +++ b/tests/test_wal_checkpoint_strategy.py @@ -0,0 +1,138 @@ +"""Tests for SessionDB WAL checkpoint strategy (issue #45383). + +Verifies that periodic checkpoints use PASSIVE mode (safe for large DBs) +while close() and pre-VACUUM paths still use TRUNCATE. +""" + +import sqlite3 +import logging +from unittest.mock import MagicMock, patch + +import pytest + +from hermes_state import SessionDB + + +@pytest.fixture() +def db(tmp_path): + """Create a SessionDB with a temp database file.""" + db_path = tmp_path / "test_state.db" + session_db = SessionDB(db_path=db_path) + yield session_db + try: + session_db.close() + except Exception: + pass + + +class TestTryWalCheckpointPassive: + """_try_wal_checkpoint() should use PASSIVE mode for periodic use.""" + + def test_checkpoint_uses_passive_mode(self, db): + """PASSIVE checkpoint does not require exclusive lock — safe for large DBs.""" + # Capture the real connection's execute before mocking + real_conn = db._conn + execute_calls = [] + + def tracking_execute(sql, *args, **kwargs): + execute_calls.append(sql) + return real_conn.execute(sql, *args, **kwargs) + + # sqlite3.Connection.execute is read-only (C extension) — replace _conn + mock_conn = MagicMock() + mock_conn.execute.side_effect = tracking_execute + mock_conn.fetchone.return_value = None + db._conn = mock_conn + + db._try_wal_checkpoint() + + passive_calls = [c for c in execute_calls if "wal_checkpoint(PASSIVE)" in c] + truncate_calls = [c for c in execute_calls if "wal_checkpoint(TRUNCATE)" in c] + assert len(passive_calls) == 1, ( + f"Expected 1 PASSIVE checkpoint call, got {len(passive_calls)}" + ) + assert len(truncate_calls) == 0, ( + "Periodic checkpoint should NOT use TRUNCATE" + ) + + def test_checkpoint_logs_warning_on_failure(self, db, caplog): + """Failed PASSIVE checkpoint logs a warning instead of silent pass.""" + mock_conn = MagicMock() + mock_conn.execute.side_effect = sqlite3.OperationalError("disk I/O error") + db._conn = mock_conn + + with caplog.at_level(logging.WARNING): + db._try_wal_checkpoint() + + assert any("WAL checkpoint (PASSIVE) failed" in r.message for r in caplog.records), ( + f"Expected warning log about PASSIVE checkpoint failure, got: {caplog.text}" + ) + + def test_checkpoint_returns_result_on_success(self, db): + """Successful PASSIVE checkpoint does not raise.""" + db._try_wal_checkpoint() + + +class TestCloseUsesTruncate: + """close() should still use TRUNCATE to shrink WAL on shutdown.""" + + def test_close_uses_truncate_mode(self, db): + """TRUNCATE at close is safe — no concurrent writers during shutdown.""" + real_conn = db._conn + execute_calls = [] + + def tracking_execute(sql, *args, **kwargs): + execute_calls.append(sql) + return real_conn.execute(sql, *args, **kwargs) + + mock_conn = MagicMock() + mock_conn.execute.side_effect = tracking_execute + db._conn = mock_conn + + db.close() + + truncate_calls = [c for c in execute_calls if "wal_checkpoint(TRUNCATE)" in c] + assert len(truncate_calls) == 1, ( + f"Expected 1 TRUNCATE checkpoint at close, got {len(truncate_calls)}" + ) + + def test_close_logs_debug_on_failure(self, db, caplog): + """Failed TRUNCATE at close logs debug (not warning — close is best-effort).""" + mock_conn = MagicMock() + mock_conn.execute.side_effect = sqlite3.OperationalError("database is locked") + db._conn = mock_conn + + with caplog.at_level(logging.DEBUG): + db.close() + + assert any("WAL checkpoint (TRUNCATE) at close failed" in r.message for r in caplog.records), ( + f"Expected debug log about TRUNCATE failure at close, got: {caplog.text}" + ) + + +class TestCheckpointFrequency: + """Checkpoint triggers every N writes.""" + + def test_checkpoint_triggers_at_interval(self, db): + """_try_wal_checkpoint is called every _CHECKPOINT_EVERY_N_WRITES writes.""" + call_count = [0] + original = db._try_wal_checkpoint + + def counting_checkpoint(): + call_count[0] += 1 + original() + + db._try_wal_checkpoint = counting_checkpoint + + # Write exactly _CHECKPOINT_EVERY_N_WRITES sessions to trigger one checkpoint + n = db._CHECKPOINT_EVERY_N_WRITES + import time as _time + for i in range(n): + db._execute_write(lambda conn, _i=i: conn.execute( + "INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)", + (f"sess_{_i}", "test", _time.time()), + )) + + assert call_count[0] == 1, ( + f"Expected 1 checkpoint after {n} writes, got {call_count[0]}" + ) diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index e364dcd3be0b..ed67099bbf57 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -2,12 +2,15 @@ import ast import os +import tempfile import threading import time from pathlib import Path from types import SimpleNamespace from unittest.mock import patch as mock_patch +import pytest + import tools.approval as approval_module from hermes_constants import get_hermes_home from tools.approval import ( @@ -55,6 +58,11 @@ class TestApprovalModeParsing: class TestSmartApproval: + def test_smart_is_the_default_approval_mode(self): + from hermes_cli.config import DEFAULT_CONFIG + + assert DEFAULT_CONFIG["approvals"]["mode"] == "smart" + def test_smart_approval_uses_call_llm(self): response = SimpleNamespace( choices=[SimpleNamespace(message=SimpleNamespace(content="APPROVE"))] @@ -68,6 +76,35 @@ class TestSmartApproval: assert mock_call.call_args.kwargs["temperature"] == 0 assert mock_call.call_args.kwargs["max_tokens"] == 16 + def test_smart_approval_does_not_allowlist_the_pattern_for_session(self, monkeypatch): + session_key = "test-smart-per-command" + command = "python -c \"print('hello')\"" + dangerous, pattern_key, _ = detect_dangerous_command(command) + assert dangerous is True + + monkeypatch.setenv("HERMES_SESSION_KEY", session_key) + monkeypatch.setenv("HERMES_EXEC_ASK", "1") + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.setattr( + approval_module, + "_get_approval_config", + lambda: {"mode": "smart"}, + ) + monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False) + monkeypatch.setattr(approval_module, "_smart_approve", lambda *_: "approve") + monkeypatch.setattr( + "tools.tirith_security.check_command_security", + lambda _command: {"action": "allow", "findings": [], "summary": ""}, + ) + approval_module.clear_session(session_key) + approval_module._permanent_approved.clear() + + result = approval_module.check_all_command_guards(command, "local") + + assert result["approved"] is True + assert result["smart_approved"] is True + assert is_approved(session_key, pattern_key) is False + class TestDetectDangerousRm: def test_rm_rf_detected(self): @@ -82,6 +119,53 @@ class TestDetectDangerousRm: assert key is not None assert "delete" in desc.lower() + def test_nonrecursive_verification_artifact_cleanup_is_not_dangerous(self): + with mock_patch("tempfile.gettempdir", return_value="/tmp"): + for prefix in ("hermes-verify-", "hermes-ad-hoc-"): + assert detect_dangerous_command(f"rm -f /tmp/{prefix}example.py") == ( + False, + None, + None, + ) + + def test_symlinked_temp_dir_only_exempts_canonical_target(self, tmp_path): + real_temp = tmp_path / "real-temp" + real_temp.mkdir() + linked_temp = tmp_path / "linked-temp" + linked_temp.symlink_to(real_temp, target_is_directory=True) + basename = "hermes-verify-example.py" + + with mock_patch("tempfile.gettempdir", return_value=str(linked_temp)): + assert detect_dangerous_command(f"rm -f {linked_temp / basename}")[0] is True + assert detect_dangerous_command(f"rm -f {real_temp / basename}") == ( + False, + None, + None, + ) + + def test_verification_cleanup_exemption_rejects_broader_deletions(self): + commands = ( + "rm -rf /tmp/hermes-verify-example.py", + "rm -f /tmp/hermes-verify-example.py /tmp/other.py", + "rm -f /tmp/nested/hermes-verify-example.py", + "rm -f /tmp/nested/../hermes-verify-example.py", + "rm -f /tmp/./hermes-verify-example.py", + "rm -f /tmp//hermes-verify-example.py", + "rm -f /tmp/a/../../tmp/hermes-verify-example.py", + "rm -f /var/tmp/hermes-verify-example.py", + "rm -f /tmp/unrelated.py", + "rm -f /tmp/hermes-verify-*", + "rm -f /tmp/hermes-verify-$(touch>/tmp/pwned).py", + "rm -f /tmp/hermes-ad-hoc-`touch>/tmp/pwned`.py", + "rm -f /tmp/hermes-verify-example.py; touch /tmp/pwned", + ) + with mock_patch("tempfile.gettempdir", return_value="/tmp"): + for command in commands: + is_dangerous, key, desc = detect_dangerous_command(command) + assert is_dangerous is True, command + assert key is not None, command + assert "delete" in desc.lower(), command + class TestWindowsShellDestructiveCommands: def test_cmd_del_requires_approval(self): @@ -1058,6 +1142,110 @@ class TestFullCommandAlwaysShown: assert result == "deny" +class TestSmartDeniedPrompt: + def test_callback_receives_smart_denied_capability(self): + captured = {} + + def callback(command, description, **kwargs): + captured.update(kwargs) + return "deny" + + result = prompt_dangerous_approval( + "rm -rf /tmp/example", + "recursive delete", + allow_permanent=False, + smart_denied=True, + approval_callback=callback, + ) + + assert result == "deny" + assert captured == {"allow_permanent": False, "smart_denied": True} + + def test_short_prompt_smart_deny_rejects_session_input(self): + with mock_patch("builtins.input", return_value="session"): + result = prompt_dangerous_approval( + "rm -rf /tmp/example", + "recursive delete", + allow_permanent=False, + smart_denied=True, + ) + + assert result == "deny" + + def test_short_prompt_smart_deny_displays_only_once_and_deny(self, capsys): + prompts = [] + + def input_once(prompt): + prompts.append(prompt) + return "deny" + + with mock_patch("builtins.input", side_effect=input_once): + prompt_dangerous_approval( + "rm -rf /tmp/example", + "recursive delete", + allow_permanent=False, + smart_denied=True, + ) + + rendered = capsys.readouterr().out + assert "[o]nce" in rendered and "[d]eny" in rendered + assert "[s]ession" not in rendered and "[a]lways" not in rendered + assert prompts == [" Choice [o/D]: "] + + @pytest.mark.parametrize( + ("lang", "once_key", "deny_key", "once_label", "deny_label"), + [ + ("tr", "b", "r", "[b]ir kez", "[r]eddet"), + ("fr", "o", "r", "[o]ne fois", "[r]efuser"), + ("ja", "o", "d", "[o]今回のみ", "[d]拒否"), + ], + ) + def test_smart_deny_uses_locale_specific_once_deny_choices( + self, monkeypatch, capsys, lang, once_key, deny_key, once_label, deny_label, + ): + monkeypatch.setenv("HERMES_LANGUAGE", lang) + from agent import i18n + i18n.reset_language_cache() + prompts = [] + + def choose_once(prompt): + prompts.append(prompt) + return once_key + + try: + with mock_patch("builtins.input", side_effect=choose_once): + result = prompt_dangerous_approval( + "rm -rf /tmp/example", "recursive delete", + allow_permanent=False, smart_denied=True, + ) + finally: + i18n.reset_language_cache() + + rendered = capsys.readouterr().out + assert result == "once" + assert once_label in rendered + assert deny_label in rendered + assert i18n.t("approval.choose_short", lang=lang).split("|")[1].strip() not in rendered + assert "/".join((once_key, deny_key.upper())) in prompts[0] + + @pytest.mark.parametrize(("lang", "forbidden"), [("tr", "o"), ("fr", "s"), ("ja", "a")]) + def test_smart_deny_rejects_localized_session_or_always_shortcuts( + self, monkeypatch, lang, forbidden, + ): + monkeypatch.setenv("HERMES_LANGUAGE", lang) + from agent import i18n + i18n.reset_language_cache() + try: + with mock_patch("builtins.input", return_value=forbidden): + result = prompt_dangerous_approval( + "rm -rf /tmp/example", "recursive delete", + allow_permanent=False, smart_denied=True, + ) + finally: + i18n.reset_language_cache() + assert result == "deny" + + class TestForkBombDetection: """The fork bomb regex must match the classic :(){ :|:& };: pattern.""" @@ -2060,7 +2248,7 @@ class TestApprovalTimeoutIsNotConsent: SESSION_KEY = "test-no-consent-session" def setup_method(self): - """Reset module state and force tight gateway_timeout for fast tests.""" + """Reset module state and force a tight approval timeout for fast tests.""" from tools import approval as mod mod._gateway_queues.clear() mod._gateway_notify_cbs.clear() @@ -2097,7 +2285,7 @@ class TestApprovalTimeoutIsNotConsent: from tools import approval as mod monkeypatch.setattr( mod, "_get_approval_config", - lambda: {"mode": "manual", "gateway_timeout": seconds, "timeout": seconds}, + lambda: {"mode": "manual", "timeout": seconds}, ) def test_timeout_returns_approved_false_with_no_consent(self, monkeypatch): @@ -2140,11 +2328,13 @@ class TestApprovalTimeoutIsNotConsent: assert "rephrase" in msg.lower() assert "different command" in msg.lower() - def test_explicit_deny_carries_same_no_consent_shape(self): + def test_explicit_deny_carries_same_no_consent_shape(self, monkeypatch): """An explicit /deny must produce the same shape as timeout — the agent should treat both identically.""" from tools import approval as mod + self._force_short_timeout(monkeypatch, seconds=60) + notified = [] mod.register_gateway_notify(self.SESSION_KEY, lambda data: notified.append(data)) diff --git a/tests/tools/test_approval_interrupt.py b/tests/tools/test_approval_interrupt.py index b991afd80883..2ef91f752c32 100644 --- a/tests/tools/test_approval_interrupt.py +++ b/tests/tools/test_approval_interrupt.py @@ -73,7 +73,7 @@ class TestApprovalInterrupt: # Force a long timeout so a *passing* test can only happen via the # interrupt path, never by the deadline elapsing. - mod._get_approval_config = lambda: {"gateway_timeout": 300} + mod._get_approval_config = lambda: {"timeout": 300} approval_data = { "command": "rm -rf /tmp/whatever", @@ -128,7 +128,7 @@ class TestApprovalInterrupt: # Short timeout so the test finishes fast via the deadline, proving the # foreign interrupt did not short-circuit the wait. - mod._get_approval_config = lambda: {"gateway_timeout": 1} + mod._get_approval_config = lambda: {"timeout": 1} approval_data = { "command": "rm -rf /tmp/whatever", diff --git a/tests/tools/test_approval_plugin_hooks.py b/tests/tools/test_approval_plugin_hooks.py index 58ccb2f8a76f..5493d274da00 100644 --- a/tests/tools/test_approval_plugin_hooks.py +++ b/tests/tools/test_approval_plugin_hooks.py @@ -13,6 +13,7 @@ import pytest import tools.approval as approval_module from tools.approval import ( check_all_command_guards, + check_execute_code_guard, set_current_session_key, clear_session, ) @@ -150,3 +151,195 @@ class TestGatewayPathFiresHooks: thread.""" +class TestSmartModeFiresHooks: + def _configure(self, monkeypatch, verdict): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + monkeypatch.setenv("HERMES_EXEC_ASK", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False) + monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(approval_module, "_smart_approve", lambda *_: verdict) + monkeypatch.setattr( + "tools.tirith_security.check_command_security", + lambda _: {"action": "allow", "findings": [], "summary": ""}, + ) + + @pytest.mark.parametrize( + ("guard", "value", "verdict", "approved", "choice", "pattern_key"), + [ + (check_all_command_guards, "rm -rf /tmp/smart-hook", "approve", True, "smart_approve", None), + (check_all_command_guards, "rm -rf /tmp/smart-hook", "deny", False, "smart_deny", None), + (check_execute_code_guard, "print('smart hook')", "approve", True, "smart_approve", "execute_code"), + (check_execute_code_guard, "print('smart hook')", "deny", False, "smart_deny", "execute_code"), + ], + ) + def test_smart_verdict_fires_redacted_pre_and_post_hooks( + self, isolated_session, monkeypatch, guard, value, verdict, approved, choice, pattern_key + ): + self._configure(monkeypatch, verdict) + secret = "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZ012345" + value = f'{value} # Authorization: Bearer {secret}' + captured = [] + + with patch( + "hermes_cli.plugins.invoke_hook", + side_effect=lambda name, **kwargs: captured.append((name, kwargs)), + ): + result = guard(value, "local") + + assert result["approved"] is approved + assert result[f"smart_{'approved' if approved else 'denied'}"] is True + assert [name for name, _ in captured] == [ + "pre_approval_request", + "post_approval_response", + ] + pre, post = (kwargs for _, kwargs in captured) + assert pre["surface"] == post["surface"] == "smart" + assert post["choice"] == choice + assert post["decided_by"] == "aux_llm" + assert pre["session_key"] == post["session_key"] == isolated_session + assert secret not in pre["command"] + assert secret not in post["command"] + assert pre["pattern_keys"] + assert pre["pattern_key"] == post["pattern_key"] + if pattern_key is not None: + assert pre["pattern_key"] == pattern_key + assert pre["pattern_keys"] == [pattern_key] + + @pytest.mark.parametrize("guard,value", [ + (check_all_command_guards, "rm -rf /tmp/smart-order"), + (check_execute_code_guard, "print('smart order')"), + ]) + def test_pre_hook_fires_before_aux_llm_decision( + self, isolated_session, monkeypatch, guard, value + ): + self._configure(monkeypatch, "approve") + events = [] + + def decide(*_): + events.append("smart_approve") + return "approve" + + monkeypatch.setattr(approval_module, "_smart_approve", decide) + with patch( + "hermes_cli.plugins.invoke_hook", + side_effect=lambda name, **kwargs: events.append(name), + ): + result = guard(value, "local") + + assert result["approved"] is True + assert events == [ + "pre_approval_request", + "smart_approve", + "post_approval_response", + ] + + @pytest.mark.parametrize("guard,value", [ + (check_all_command_guards, "rm -rf /tmp/smart-force-redaction"), + (check_execute_code_guard, "print('smart force redaction')"), + ]) + def test_smart_observer_redaction_is_forced_when_config_disables_redaction( + self, isolated_session, monkeypatch, guard, value + ): + self._configure(monkeypatch, "approve") + force_values = [] + + def redact(text, *, force=False): + force_values.append(force) + return f"redacted:{text}" + + with ( + patch("agent.redact.redact_sensitive_text", side_effect=redact), + patch("hermes_cli.plugins.invoke_hook"), + ): + result = guard(value, "local") + + assert result["approved"] is True + assert force_values == [True, True] + + @pytest.mark.parametrize("guard,value", [ + (check_all_command_guards, "rm -rf /tmp/smart-hook-crash"), + (check_execute_code_guard, "print('smart hook crash')"), + ]) + @pytest.mark.parametrize("verdict,approved", [("approve", True), ("deny", False)]) + def test_observer_exception_never_changes_smart_verdict( + self, isolated_session, monkeypatch, guard, value, verdict, approved + ): + self._configure(monkeypatch, verdict) + with patch( + "hermes_cli.plugins.invoke_hook", + side_effect=RuntimeError("observer failed"), + ): + result = guard(value, "local") + assert result["approved"] is approved + + @pytest.mark.parametrize("guard,value", [ + (check_all_command_guards, "rm -rf /tmp/smart-redactor-crash"), + (check_execute_code_guard, "print('smart redactor crash')"), + ]) + @pytest.mark.parametrize("verdict,approved", [("approve", True), ("deny", False)]) + def test_redactor_exception_never_changes_smart_verdict_or_leaks_payload( + self, isolated_session, monkeypatch, guard, value, verdict, approved + ): + self._configure(monkeypatch, verdict) + captured = [] + + def fail_observer_redaction(text, *, force=False): + if force: + raise RuntimeError("observer redactor failed") + return text + + with ( + patch("agent.redact.redact_sensitive_text", side_effect=fail_observer_redaction), + patch( + "hermes_cli.plugins.invoke_hook", + side_effect=lambda name, **kwargs: captured.append((name, kwargs)), + ), + ): + result = guard(value, "local") + assert result["approved"] is approved + assert captured == [] + + @pytest.mark.parametrize("guard,first_value,second_value", [ + ( + check_all_command_guards, + "rm -rf /tmp/first-smart-command", + "rm -rf /tmp/second-smart-command", + ), + ( + check_execute_code_guard, + "print('first smart script')", + "print('second smart script')", + ), + ]) + def test_smart_approval_is_per_command( + self, isolated_session, monkeypatch, guard, first_value, second_value + ): + verdicts = iter(("approve", "deny")) + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + monkeypatch.setenv("HERMES_EXEC_ASK", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False) + monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(approval_module, "_smart_approve", lambda *_: next(verdicts)) + monkeypatch.setattr( + "tools.tirith_security.check_command_security", + lambda _: {"action": "allow", "findings": [], "summary": ""}, + ) + captured = [] + with patch( + "hermes_cli.plugins.invoke_hook", + side_effect=lambda name, **kwargs: captured.append((name, kwargs)), + ): + first = guard(first_value, "local") + second = guard(second_value, "local") + + assert first["approved"] is True + assert second["approved"] is False + assert [kwargs["choice"] for name, kwargs in captured if name == "post_approval_response"] == [ + "smart_approve", + "smart_deny", + ] + + diff --git a/tests/tools/test_async_delegation.py b/tests/tools/test_async_delegation.py index 7714d3c8c08a..37aae286394a 100644 --- a/tests/tools/test_async_delegation.py +++ b/tests/tools/test_async_delegation.py @@ -5,7 +5,11 @@ onto the shared process_registry.completion_queue, the rich re-injection block formatting, capacity rejection, and crash handling. """ +import json +import os import queue +import subprocess +import sys import threading import time @@ -21,6 +25,13 @@ def _clean_state(): while not process_registry.completion_queue.empty(): process_registry.completion_queue.get_nowait() yield + # Give just-released workers a beat to finalize BEFORE draining, so their + # completion events land now instead of leaking into the next test's + # queue (worker threads push events asynchronously; a drain that races an + # in-flight _finalize misses it). + deadline = time.monotonic() + 2.0 + while ad.active_count() and time.monotonic() < deadline: + time.sleep(0.02) ad._reset_for_tests() while not process_registry.completion_queue.empty(): process_registry.completion_queue.get_nowait() @@ -35,11 +46,30 @@ def _drain_one(timeout=5.0): return None +def _drain_for(delegation_id, timeout=5.0): + """Drain until the event for *delegation_id* appears (discarding others). + + Completion events are pushed asynchronously by worker threads, so a + straggler from a PREVIOUS test can land after that test's teardown drain + and leak into the current test's queue. Matching on delegation_id makes + the assertion immune to that cross-test leak. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not process_registry.completion_queue.empty(): + evt = process_registry.completion_queue.get_nowait() + if evt.get("delegation_id") == delegation_id: + return evt + continue + time.sleep(0.02) + return None + + def test_dispatch_returns_immediately_without_blocking(): gate = threading.Event() def runner(): - gate.wait(timeout=5) + gate.wait(timeout=60) return {"status": "completed", "summary": "done", "api_calls": 1, "duration_seconds": 0.1, "model": "m"} @@ -66,7 +96,7 @@ def test_async_executor_workers_are_daemon_threads(): gate = threading.Event() def runner(): - gate.wait(timeout=5) + gate.wait(timeout=60) return {"status": "completed", "summary": "done"} res = ad.dispatch_async_delegation( @@ -144,7 +174,7 @@ def test_dispatch_rejected_at_capacity(): ev = threading.Event() def blocker(): - ev.wait(timeout=5) + ev.wait(timeout=60) return {"status": "completed", "summary": "x"} for i in range(2): @@ -163,30 +193,18 @@ def test_dispatch_rejected_at_capacity(): ev.set() -def test_crashed_runner_produces_error_completion(): - def boom(): - raise RuntimeError("subagent exploded") - - r = ad.dispatch_async_delegation( - goal="risky", context=None, toolsets=None, role="leaf", model="m", - session_key="", runner=boom, max_async_children=3, - ) - assert r["status"] == "dispatched" - evt = _drain_one() - assert evt is not None - assert evt["status"] == "error" - text = format_process_notification(evt) - assert text is not None - assert "did not complete successfully" in text - assert "subagent exploded" in text - - def test_interrupt_all_signals_running_children(): ev = threading.Event() interrupted = {"count": 0} + # No short internal timeout: the blocker holds until interrupt_fn fires. + # The old ev.wait(timeout=5) made this test a change-detector for CI + # worker load — on a CPU-starved runner the 5s expired before + # interrupt_all() ran, the record finalized, and interrupt_all() found + # nothing running (n == 0). The pytest-level timeout is the real + # runaway guard. def blocker(): - ev.wait(timeout=5) + ev.wait(timeout=60) return {"status": "interrupted", "summary": None, "error": "cancelled"} @@ -194,7 +212,7 @@ def test_interrupt_all_signals_running_children(): interrupted["count"] += 1 ev.set() - ad.dispatch_async_delegation( + r = ad.dispatch_async_delegation( goal="long task", context=None, toolsets=None, role="leaf", model="m", session_key="", runner=blocker, interrupt_fn=interrupt_fn, max_async_children=3, @@ -202,8 +220,11 @@ def test_interrupt_all_signals_running_children(): n = ad.interrupt_all(reason="test") assert n == 1 assert interrupted["count"] == 1 - # child still emits a completion event after interrupt - evt = _drain_one() + # child still emits a completion event after interrupt. Match on THIS + # delegation's id — straggler 'completed' events from a previous test's + # workers can finalize after that test's teardown drain and leak into + # this queue (observed on loaded CI workers). + evt = _drain_for(r["delegation_id"]) assert evt is not None assert evt["status"] == "interrupted" @@ -223,6 +244,181 @@ def test_completed_records_pruned_to_cap(): assert len(ad.list_async_delegations()) <= ad._MAX_RETAINED_COMPLETED +def test_completion_is_persisted_and_delivery_can_be_acknowledged(tmp_path, monkeypatch): + """A finished child remains pending on disk until its queue consumer acks it.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + dispatched = ad.dispatch_async_delegation( + goal="durable", context="ctx", toolsets=["terminal"], role="leaf", + model="m", session_key="owner", parent_session_id="parent", + runner=lambda: {"status": "completed", "summary": "survived"}, + ) + assert _drain_one() is not None + + restored = queue.Queue() + assert ad.restore_undelivered_completions(restored) == 1 + row = ad.get_durable_delegation(dispatched["delegation_id"]) + assert row["origin_session"] == "owner" + assert row["state"] == "completed" + assert row["result"]["summary"] == "survived" + assert row["delivery_state"] == "pending" + # Queue publication/restoration is not a destination delivery attempt. + assert row["delivery_attempts"] == 0 + + assert ad.mark_completion_delivered(dispatched["delegation_id"]) + assert ad.restore_undelivered_completions(queue.Queue()) == 0 + assert ad.get_durable_delegation(dispatched["delegation_id"])["delivery_state"] == "delivered" + + +def test_real_process_restart_restores_owned_completion_once(tmp_path): + """Real-import E2E: a fresh interpreter restores a prior process's result.""" + repo = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + env = {**os.environ, "HERMES_HOME": str(tmp_path), "PYTHONPATH": repo} + producer = r''' +import time +from tools import async_delegation as ad +r = ad.dispatch_async_delegation( + goal="restart", context=None, toolsets=None, role="leaf", model="m", + session_key="owner-session", parent_session_id="durable-parent", + runner=lambda: {"status": "completed", "summary": "after restart"}, +) +deadline = time.time() + 5 +while ad.active_count() and time.time() < deadline: + time.sleep(.01) +print(r["delegation_id"]) +''' + first = subprocess.run( + [sys.executable, "-c", producer], cwd=repo, env=env, + text=True, capture_output=True, timeout=15, check=True, + ) + delegation_id = first.stdout.strip().splitlines()[-1] + + consumer = r''' +import json +from tools.process_registry import process_registry +evt = process_registry.completion_queue.get_nowait() +print(json.dumps(evt, sort_keys=True)) +''' + second = subprocess.run( + [sys.executable, "-c", consumer], cwd=repo, env=env, + text=True, capture_output=True, timeout=15, check=True, + ) + evt = json.loads(second.stdout.strip().splitlines()[-1]) + assert evt["delegation_id"] == delegation_id + assert evt["session_key"] == "owner-session" + assert evt["parent_session_id"] == "durable-parent" + assert evt["summary"] == "after restart" + + acker = f''' +from tools import async_delegation as ad +assert ad.mark_completion_delivered({delegation_id!r}) +''' + subprocess.run( + [sys.executable, "-c", acker], cwd=repo, env=env, + text=True, capture_output=True, timeout=15, check=True, + ) + probe = subprocess.run( + [sys.executable, "-c", "from tools.process_registry import process_registry; print(process_registry.completion_queue.qsize())"], + cwd=repo, env=env, text=True, capture_output=True, timeout=15, check=True, + ) + assert probe.stdout.strip().splitlines()[-1] == "0" + + +def test_submit_failure_removes_durable_running_record(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + class _BrokenExecutor: + def submit(self, *_args, **_kwargs): + raise RuntimeError("submit failed") + + monkeypatch.setattr(ad, "_get_executor", lambda _max_workers: _BrokenExecutor()) + result = ad.dispatch_async_delegation( + goal="never ran", context=None, toolsets=None, role="leaf", model="m", + session_key="owner", runner=lambda: {}, + ) + + assert result["status"] == "rejected" + with ad._DB_LOCK, ad._connect() as conn: + assert conn.execute("SELECT COUNT(*) FROM async_delegations").fetchone()[0] == 0 + + +def test_pending_retention_prunes_delivered_before_undelivered(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(ad, "_MAX_RETAINED_COMPLETED", 2) + for index, delivery_state in enumerate(("pending", "delivered", "pending")): + delegation_id = f"deleg_{index}" + record = { + "delegation_id": delegation_id, + "session_key": "owner", + "origin_ui_session_id": "", + "parent_session_id": None, + "dispatched_at": float(index + 1), + } + ad._persist_dispatch(record) + ad._persist_completion( + { + "delegation_id": delegation_id, + "status": "completed", + "completed_at": float(index + 1), + }, + {"status": "completed", "summary": delegation_id}, + ) + if delivery_state == "delivered": + ad.mark_completion_delivered(delegation_id) + + ad._prune_durable_records() + + assert ad.get_durable_delegation("deleg_0") is not None + assert ad.get_durable_delegation("deleg_1") is None + assert ad.get_durable_delegation("deleg_2") is not None + + +def test_recover_marks_abandoned_running_record_unknown(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + record = { + "delegation_id": "deleg_abandoned", + "session_key": "owner", + "origin_ui_session_id": "", + "parent_session_id": None, + "dispatched_at": 1.0, + } + ad._persist_dispatch(record) + with ad._DB_LOCK, ad._connect() as conn: + conn.execute( + "UPDATE async_delegations SET owner_pid=?, owner_started_at=NULL WHERE delegation_id=?", + (99999999, "deleg_abandoned"), + ) + + assert ad.recover_abandoned_delegations() == 1 + durable = ad.get_durable_delegation("deleg_abandoned") + assert durable["state"] == "unknown" + assert durable["delivery_state"] == "pending" + restored = queue.Queue() + assert ad.restore_undelivered_completions(restored) == 1 + assert restored.get_nowait()["status"] == "unknown" + + +def test_durable_delivery_claim_is_exclusive_and_retryable(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + record = { + "delegation_id": "deleg_claim", "session_key": "owner", + "origin_ui_session_id": "", "parent_session_id": None, + "dispatched_at": 1.0, + } + ad._persist_dispatch(record) + ad._persist_completion( + {"delegation_id": "deleg_claim", "status": "completed", "completed_at": 2.0}, + {"status": "completed", "summary": "done"}, + ) + + assert ad.claim_completion_delivery("deleg_claim", "consumer-a") + assert not ad.claim_completion_delivery("deleg_claim", "consumer-b") + assert ad.release_completion_delivery("deleg_claim", "consumer-a") + assert ad.claim_completion_delivery("deleg_claim", "consumer-b") + assert ad.complete_completion_delivery("deleg_claim", "consumer-b") + assert not ad.claim_completion_delivery("deleg_claim", "consumer-c") + assert ad.get_durable_delegation("deleg_claim")["delivery_state"] == "delivered" + + # --------------------------------------------------------------------------- # Integration: delegate_task(background=True) routing # --------------------------------------------------------------------------- @@ -247,7 +443,7 @@ def test_delegate_task_background_routes_async_and_does_not_block(monkeypatch): gate = threading.Event() def slow_child(task_index, goal, child=None, parent_agent=None, **kw): - gate.wait(timeout=5) # a sync impl would hang delegate_task here + gate.wait(timeout=60) # a sync impl would hang delegate_task here return { "task_index": 0, "status": "completed", "summary": f"done: {goal}", "api_calls": 1, "duration_seconds": 0.1, "model": "m", @@ -375,7 +571,7 @@ def test_delegate_task_background_batch_runs_as_one_unit(monkeypatch): gate = threading.Event() def _blocking_child(task_index, goal, child=None, parent_agent=None, **kw): - gate.wait(timeout=5) + gate.wait(timeout=60) return { "task_index": task_index, "status": "completed", "summary": f"done: {goal}", "api_calls": 1, @@ -529,7 +725,7 @@ def test_delegate_task_background_detaches_child_from_parent(monkeypatch): gate = threading.Event() def slow_child(task_index, goal, child=None, parent_agent=None, **kw): - gate.wait(timeout=5) + gate.wait(timeout=60) return {"task_index": 0, "status": "completed", "summary": "ok"} def build_and_register(**kw): @@ -561,7 +757,7 @@ def test_concurrent_dispatch_respects_capacity(): gate = threading.Event() def blocker(): - gate.wait(timeout=5) + gate.wait(timeout=60) return {"status": "completed", "summary": "x"} results = [] diff --git a/tests/tools/test_base_environment.py b/tests/tools/test_base_environment.py index d629d95ea299..3d4eb8202f3e 100644 --- a/tests/tools/test_base_environment.py +++ b/tests/tools/test_base_environment.py @@ -6,7 +6,7 @@ init_session() failure handling, and the CWD marker contract. from unittest.mock import MagicMock -from tools.environments.base import BaseEnvironment +from tools.environments.base import BaseEnvironment, _BoundedOutputCollector class _TestableEnv(BaseEnvironment): @@ -22,6 +22,41 @@ class _TestableEnv(BaseEnvironment): pass +class TestBoundedOutputCollector: + def test_large_stream_retains_bounded_head_and_tail(self): + collector = _BoundedOutputCollector(1_000) + collector.append("HEAD-SENTINEL\n") + for _ in range(2_000): + collector.append("x" * 4_096) + collector.append("\nTAIL-SENTINEL") + + rendered = collector.render() + + assert collector.total_chars > 8_000_000 + assert collector.buffered_chars <= 1_000 + assert len(rendered) <= 1_000 + assert rendered.startswith("HEAD-SENTINEL") + assert rendered.endswith("TAIL-SENTINEL") + assert "[OUTPUT TRUNCATED" in rendered + + def test_small_stream_is_unchanged(self): + collector = _BoundedOutputCollector(100) + collector.append("hello ") + collector.append("world") + + assert collector.render() == "hello world" + + def test_required_status_suffix_stays_inside_limit(self): + collector = _BoundedOutputCollector(120) + collector.append("A" * 10_000) + + rendered = collector.render(suffix="\n[Command timed out after 1s]") + + assert len(rendered) <= 120 + assert rendered.endswith("[Command timed out after 1s]") + assert "[OUTPUT TRUNCATED" in rendered + + class TestWrapCommand: def test_basic_shape(self): env = _TestableEnv() @@ -161,8 +196,8 @@ class TestAtomicSnapshotWrite: captured = {} def fake_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None): - captured["cmd"] = cmd_string - raise RuntimeError("stop after capture") # we only need the script + captured.setdefault("cmd", cmd_string) # only the bootstrap; ignore the failure-path probe + raise RuntimeError("stop after capture") env._run_bash = fake_run_bash # type: ignore[assignment] try: @@ -188,7 +223,7 @@ class TestAtomicSnapshotWrite: captured = {} def fake_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None): - captured["cmd"] = cmd_string + captured.setdefault("cmd", cmd_string) # only the bootstrap; ignore the failure-path probe raise RuntimeError("stop after capture") env._run_bash = fake_run_bash # type: ignore[assignment] @@ -437,6 +472,41 @@ class TestInitSessionFailure: assert len(calls) == 1 assert calls[0]["login"] is True + def test_prefer_nonlogin_when_login_bash_is_dead(self): + """Login snapshot failure + working non-login probe → don't use bash -l.""" + env = _TestableEnv() + + def mock_run_bash(cmd, *, login=False, timeout=120, stdin_data=None): + mock = MagicMock() + mock.poll.return_value = 0 + mock.stdout = iter([]) + if login: + mock.returncode = 1 + else: + mock.returncode = 0 + return mock + + env._run_bash = mock_run_bash + env.init_session() + + assert env._snapshot_ready is False + assert env._prefer_nonlogin is True + + calls = [] + + def track_run_bash(cmd, *, login=False, timeout=120, stdin_data=None): + calls.append({"login": login}) + mock = MagicMock() + mock.poll.return_value = 0 + mock.returncode = 0 + mock.stdout = iter([]) + return mock + + env._run_bash = track_run_bash + env.execute("echo test") + + assert calls[0]["login"] is False + class TestCwdMarker: def test_marker_contains_session_id(self): diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 9f4a6a5ca051..44b5f1a5dbd1 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -6,7 +6,7 @@ import base64 import json import os import sys -from typing import Any, Dict, List +from typing import Any, Dict, List, cast from unittest.mock import MagicMock, patch import pytest @@ -69,9 +69,17 @@ class TestSchema: actions = set(COMPUTER_USE_SCHEMA["parameters"]["properties"]["action"]["enum"]) assert actions >= { "capture", "click", "double_click", "right_click", "middle_click", - "drag", "scroll", "type", "key", "wait", "list_apps", "focus_app", + "drag", "scroll", "type", "key", "wait", "list_apps", "list_windows", + "focus_app", } + def test_schema_exposes_exact_capture_targeting(self): + from tools.computer_use.schema import COMPUTER_USE_SCHEMA + + props = COMPUTER_USE_SCHEMA["parameters"]["properties"] + assert props["pid"]["type"] == "integer" + assert props["window_id"]["type"] == "integer" + def test_capture_mode_enum_has_som_vision_ax(self): from tools.computer_use.schema import COMPUTER_USE_SCHEMA modes = set(COMPUTER_USE_SCHEMA["parameters"]["properties"]["mode"]["enum"]) @@ -165,6 +173,12 @@ class TestDispatch: assert "apps" in parsed assert parsed["count"] == 0 + def test_list_windows_returns_json(self, noop_backend): + from tools.computer_use.tool import handle_computer_use + out = handle_computer_use({"action": "list_windows"}) + parsed = json.loads(out) + assert parsed == {"windows": [], "count": 0} + def test_wait_clamps_long_waits(self, noop_backend): from tools.computer_use.tool import handle_computer_use # The backend's default wait() uses time.sleep with clamping. @@ -265,6 +279,19 @@ class TestDispatch: out = handle_computer_use({"action": "set_value"}) parsed = json.loads(out) assert "error" in parsed + + def test_capture_forwards_exact_pid_window_target(self, noop_backend): + from tools.computer_use.tool import handle_computer_use + + handle_computer_use({ + "action": "capture", "mode": "ax", "pid": 23502, "window_id": 58720504, + }) + + capture_kw = next(c[1] for c in noop_backend.calls if c[0] == "capture") + assert capture_kw == { + "mode": "ax", "app": None, "pid": 23502, "window_id": 58720504, + } + def test_capture_after_skipped_when_action_failed(self, noop_backend): """capture_after must not fire when res.ok=False (regression guard). @@ -1421,6 +1448,45 @@ def _make_cua_backend_with_windows(windows: List[Dict[str, Any]]): return backend +def _make_cua_backend_with_windows_and_apps( + windows: List[Dict[str, Any]], apps: List[Dict[str, Any]] +): + """Construct a backend whose mocked session serves list_windows/list_apps.""" + from tools.computer_use.cua_backend import CuaDriverBackend + + backend = CuaDriverBackend() + backend._session = MagicMock() + + def _call_tool(name, args): + if name == "list_windows": + return { + "data": "", + "images": [], + "structuredContent": {"windows": windows}, + "isError": False, + } + if name == "list_apps": + # cua-driver MCP puts the canonical app objects in + # structuredContent; `data` is only a human-readable summary. + return { + "data": f"✅ Found {len(apps)} app(s)", + "images": [], + "structuredContent": {"apps": apps}, + "isError": False, + } + if name == "get_window_state": + return { + "data": '✅ FreeCAD — 0 elements\n', + "images": [], + "structuredContent": None, + "isError": False, + } + raise AssertionError(f"unexpected tool call: {name}") + + backend._session.call_tool.side_effect = _call_tool + return backend + + class TestCuaDriverSessionReconnect: """Verify reconnect-once on a closed-resource error. After the lifecycle-owner refactor (Sun Jun 21 2026) the session no longer goes @@ -1595,6 +1661,32 @@ class TestCuaDriverSessionReconnect: assert "AXButton" in out["data"] assert "7 elements" in out["data"] + def test_cli_fallback_preserves_logical_error(self, monkeypatch): + from typing import Any, cast + from tools.computer_use.cua_backend import _CuaDriverSession + + session = cast(Any, _CuaDriverSession.__new__(_CuaDriverSession)) + + class FakeProc: + stdout = '{"isError": true, "message": "bad target"}' + stderr = "" + returncode = 0 + + monkeypatch.setattr("subprocess.run", lambda *a, **kw: FakeProc()) + + out = session._call_tool_via_cli("list_windows", {}, 30.0) + + assert out["isError"] is True + + def test_extract_tool_result_missing_mock_error_flag_is_not_error(self): + from tools.computer_use.cua_backend import _extract_tool_result + + result = MagicMock() + result.content = [] + result.structuredContent = None + + assert _extract_tool_result(result)["isError"] is False + class TestCaptureEmptyResultClipFallback: """When the MCP bridge returns a degenerate/empty get_window_state result @@ -1740,6 +1832,231 @@ class TestCaptureAppFilterNoMatch: assert backend._active_pid == 200 assert backend._active_window_id == 2 + def test_app_filter_falls_back_to_list_apps_metadata(self): + windows = [ + {"app_name": "Qt6Application", "pid": 7675, "window_id": 42, + "is_on_screen": True, "title": "FreeCAD 1.1.1", "z_index": 0}, + ] + apps = [ + {"name": "FreeCAD", "bundle_id": "org.freecad.FreeCAD", "pid": 7675}, + ] + backend = _make_cua_backend_with_windows_and_apps(windows, apps) + + cap = backend.capture(mode="ax", app="org.freecad.FreeCAD") + + assert cap.app == "Qt6Application" + assert backend._active_pid == 7675 + assert backend._active_window_id == 42 + + def test_exact_metadata_alias_beats_broader_direct_window_name(self): + windows = [ + {"app_name": "Visual Studio Code", "pid": 100, "window_id": 1, + "is_on_screen": True, "title": "Visual Studio Code", "z_index": 0}, + {"app_name": "Qt6Application", "pid": 200, "window_id": 2, + "is_on_screen": True, "title": "Code", "z_index": 1}, + ] + apps = [{"name": "Code", "bundle_id": "org.example.Code", "pid": 200}] + backend = _make_cua_backend_with_windows_and_apps(windows, apps) + + cap = backend.capture(mode="ax", app="Code") + + assert cap.app == "Qt6Application" + assert backend._active_pid == 200 + assert backend._active_window_id == 2 + + def test_exact_pid_window_capture_bypasses_window_discovery(self): + from tools.computer_use.cua_backend import CuaDriverBackend + + backend = CuaDriverBackend() + session = MagicMock() + + def _call_tool(name, args): + assert name != "list_windows", "exact target must bypass discovery" + assert name == "get_window_state" + assert args["pid"] == 7675 + assert args["window_id"] == 42 + return { + "data": "✅ FreeCAD — 0 elements", "images": [], + "structuredContent": None, "isError": False, + } + + session.call_tool.side_effect = _call_tool + backend._session = session + + cap = backend.capture(mode="ax", pid=7675, window_id=42) + + assert cap.app == "" + assert backend._active_pid == 7675 + assert backend._active_window_id == 42 + + @pytest.mark.parametrize( + ("pid", "window_id", "diagnostic"), + [ + (None, 2, "both"), + ("bad", 2, "positive integer"), + (True, 2, "positive integer"), + (1, False, "positive integer"), + (-1, 2, "positive integer"), + (1, 0, "positive integer"), + ], + ) + def test_failed_exact_capture_clears_prior_target_and_tokens( + self, pid, window_id, diagnostic, + ): + from tools.computer_use.cua_backend import CuaDriverBackend + + backend = CuaDriverBackend() + session = MagicMock() + session.call_tool.return_value = { + "data": "ok", "images": [], "structuredContent": None, "isError": False, + } + backend._session = session + backend._active_pid = 111 + backend._active_window_id = 222 + backend._last_app = "Previous" + backend._last_target = {"pid": 111, "window_id": 222} + backend._snapshot_tokens = {1: "stale-token"} + + cap = backend.capture(mode="ax", pid=pid, window_id=window_id) + + assert cap.width == 0 and cap.height == 0 + assert diagnostic in cap.window_title + assert backend._active_pid is None + assert backend._active_window_id is None + assert backend._last_target is None + assert backend._snapshot_tokens == {} + assert backend.click(x=1, y=2).ok is False + session.call_tool.assert_not_called() + + def test_list_windows_drops_nonpositive_and_boolean_identifiers(self): + from tools.computer_use.cua_backend import CuaDriverBackend + + backend = CuaDriverBackend() + session = MagicMock() + session.call_tool.return_value = { + "data": "", "images": [], "isError": False, + "structuredContent": {"windows": [ + {"app_name": "Good", "pid": 12, "window_id": 34, + "is_on_screen": True, "z_index": 0}, + {"app_name": "Bool", "pid": True, "window_id": 2, + "is_on_screen": True, "z_index": 1}, + {"app_name": "Zero", "pid": 0, "window_id": 3, + "is_on_screen": True, "z_index": 2}, + {"app_name": "Negative", "pid": 4, "window_id": -1, + "is_on_screen": True, "z_index": 3}, + ]}, + } + backend._session = session + + assert backend.list_windows() == [{ + "app_name": "Good", "pid": 12, "window_id": 34, + "off_screen": False, "title": "", "z_index": 0, + }] + + def test_capture_transport_exception_disarms_prior_target(self): + from tools.computer_use.cua_backend import CuaDriverBackend + + backend = CuaDriverBackend() + session = MagicMock() + session.call_tool.side_effect = RuntimeError("list_windows failed") + backend._session = session + backend._active_pid = 111 + backend._active_window_id = 222 + backend._last_target = {"pid": 111, "window_id": 222} + backend._snapshot_tokens = {1: "stale-token"} + + with pytest.raises(RuntimeError, match="list_windows failed"): + backend.capture(mode="ax") + + assert backend._active_pid is None + assert backend._active_window_id is None + assert backend._last_target is None + assert backend._snapshot_tokens == {} + + def test_focus_changes_target_and_clears_snapshot_tokens(self): + windows = [ + {"app_name": "New", "pid": 333, "window_id": 444, + "is_on_screen": True, "z_index": 0}, + ] + backend = _make_cua_backend_with_windows(windows) + backend._active_pid = 111 + backend._active_window_id = 222 + backend._snapshot_tokens = {1: "stale-token"} + + res = backend.focus_app("New") + + assert res.ok is True + assert backend._active_pid == 333 + assert backend._active_window_id == 444 + assert backend._snapshot_tokens == {} + + def test_get_window_state_exception_disarms_selected_target_and_tokens(self): + from tools.computer_use.cua_backend import CuaDriverBackend + + backend = CuaDriverBackend() + session = MagicMock() + session.call_tool.side_effect = [ + {"data": "", "images": [], "isError": False, + "structuredContent": {"windows": [{ + "app_name": "New", "pid": 333, "window_id": 444, + "is_on_screen": True, "z_index": 0, + }]}}, + RuntimeError("get_window_state failed"), + ] + backend._session = session + backend._active_pid = 111 + backend._active_window_id = 222 + backend._snapshot_tokens = {1: "stale-token"} + + with pytest.raises(RuntimeError, match="get_window_state failed"): + backend.capture(mode="ax", app="New") + + assert backend._active_pid is None + assert backend._active_window_id is None + assert backend._last_target is None + assert backend._snapshot_tokens == {} + + @pytest.mark.parametrize("tool_name", ["list_windows", "get_window_state"]) + def test_capture_logical_driver_error_disarms_target_and_tokens(self, tool_name): + from tools.computer_use.cua_backend import CuaDriverBackend + + backend = CuaDriverBackend() + session = MagicMock() + window_result = { + "data": "", "images": [], "isError": False, + "structuredContent": {"windows": [{ + "app_name": "New", "pid": 333, "window_id": 444, + "is_on_screen": True, "z_index": 0, + }]}, + } + logical_error = { + "data": f"{tool_name} rejected target", "images": [], + "structuredContent": None, "isError": True, + } + session.call_tool.side_effect = ( + [logical_error] + if tool_name == "list_windows" + else [window_result, logical_error] + ) + backend._session = session + backend._active_pid = 111 + backend._active_window_id = 222 + backend._last_app = "Previous" + backend._last_target = {"pid": 111, "window_id": 222} + backend._snapshot_tokens = {1: "stale-token"} + + with pytest.raises(RuntimeError, match=f"cua-driver {tool_name} failed"): + backend.capture(mode="ax", app="New") + + assert backend._active_pid is None + assert backend._active_window_id is None + assert backend._last_app is None + assert backend._last_target is None + assert backend._snapshot_tokens == {} + calls_before = session.call_tool.call_count + assert backend.click(x=1, y=2).ok is False + assert session.call_tool.call_count == calls_before + def test_no_app_filter_still_picks_frontmost(self): """When no app= is given, capture continues to pick the frontmost window — the no-match early-return must not fire on the empty case.""" @@ -1798,6 +2115,181 @@ class TestFocusAppFilterNoMatch: assert backend._active_pid == 200 assert backend._active_window_id == 2 + def test_focus_app_falls_back_to_list_apps_metadata(self): + windows = [ + {"app_name": "Qt6Application", "pid": 7675, "window_id": 42, + "is_on_screen": True, "title": "FreeCAD 1.1.1", "z_index": 0}, + ] + apps = [ + {"name": "FreeCAD", "bundle_id": "org.freecad.FreeCAD", "pid": 7675}, + ] + backend = _make_cua_backend_with_windows_and_apps(windows, apps) + + res = backend.focus_app("FreeCAD") + + assert res.ok is True + assert backend._active_pid == 7675 + assert backend._active_window_id == 42 + + def test_focus_exact_metadata_alias_beats_broader_direct_window_name(self): + windows = [ + {"app_name": "Visual Studio Code", "pid": 100, "window_id": 1, + "is_on_screen": True, "title": "Visual Studio Code", "z_index": 0}, + {"app_name": "Qt6Application", "pid": 200, "window_id": 2, + "is_on_screen": True, "title": "Code", "z_index": 1}, + ] + apps = [{"name": "Code", "bundle_id": "org.example.Code", "pid": 200}] + backend = _make_cua_backend_with_windows_and_apps(windows, apps) + + res = backend.focus_app("Code") + + assert res.ok is True + assert backend._active_pid == 200 + assert backend._active_window_id == 2 + + def test_title_fallback_only_targets_nameless_windows(self): + windows = [ + {"app_name": "計算機", "pid": 200, "window_id": 2, + "is_on_screen": True, "title": "Calculator", "z_index": 0}, + {"app_name": "", "pid": 300, "window_id": 3, + "is_on_screen": True, "title": "Calculator", "z_index": 1}, + ] + backend = _make_cua_backend_with_windows_and_apps(windows, []) + + cap = backend.capture(mode="ax", app="Calculator") + + assert backend._active_pid == 300 + assert backend._active_window_id == 3 + assert cap.app == "" + + def test_title_fallback_survives_list_apps_failure(self): + windows = [ + {"app_name": "", "pid": 300, "window_id": 3, + "is_on_screen": True, "title": "Mousepad", "z_index": 0}, + ] + backend = _make_cua_backend_with_windows(windows) + session = cast(Any, backend._session) + session.call_tool.side_effect = [ + { + "data": "", "images": [], "isError": False, + "structuredContent": {"windows": windows}, + }, + RuntimeError("list_apps unavailable"), + { + "data": "✅ Mousepad — 0 elements", "images": [], "isError": False, + "structuredContent": None, + }, + ] + + cap = backend.capture(mode="ax", app="Mousepad") + + assert cap.app == "" + assert backend._active_pid == 300 + assert backend._active_window_id == 3 + + def test_focus_app_title_fallback_targets_a_nameless_window(self): + windows = [ + {"app_name": "", "pid": 300, "window_id": 3, + "is_on_screen": True, "title": "Mousepad", "z_index": 0}, + ] + backend = _make_cua_backend_with_windows_and_apps(windows, []) + + res = backend.focus_app("Mousepad") + + assert res.ok is True + assert backend._active_pid == 300 + assert backend._active_window_id == 3 + + def test_installed_only_metadata_cannot_target_a_pid_zero_window(self): + windows = [ + {"app_name": "", "pid": 0, "window_id": 7, + "is_on_screen": True, "title": "Desktop", "z_index": 0}, + ] + apps = [ + {"name": "FreeCAD", "bundle_id": "org.freecad.FreeCAD", + "pid": 0, "running": False}, + ] + backend = _make_cua_backend_with_windows_and_apps(windows, apps) + + cap = backend.capture(mode="ax", app="org.freecad.FreeCAD") + + assert cap.app == "" + assert backend._active_pid is None + assert backend._active_window_id is None + + +class TestCaptureAfterExactTarget: + def test_followup_capture_reuses_exact_window_identity(self): + from tools.computer_use.backend import ActionResult, CaptureResult + from tools.computer_use.tool import _maybe_follow_capture + + class GenericWindowBackend: + _last_app = "Qt6Application" + _last_target = {"pid": 7675, "window_id": 42} + + def __init__(self): + self.capture_calls = [] + + def capture(self, mode="som", app=None, pid=None, window_id=None): + self.capture_calls.append({ + "mode": mode, "app": app, "pid": pid, "window_id": window_id, + }) + return CaptureResult( + mode=mode, width=0, height=0, png_b64=None, + elements=[], app="Qt6Application", window_title="FreeCAD", + ) + + backend = GenericWindowBackend() + _maybe_follow_capture(cast(Any, backend), ActionResult(ok=True, action="click"), True) + + assert backend.capture_calls == [{ + "mode": "som", "app": None, "pid": 7675, "window_id": 42, + }] + + def test_dispatch_capture_after_reuses_metadata_resolved_identity(self, monkeypatch): + from tools.computer_use import tool as computer_use_tool + + windows = [ + {"app_name": "Qt6Application", "pid": 100, "window_id": 1, + "is_on_screen": True, "title": "Other Qt App", "z_index": 0}, + {"app_name": "Qt6Application", "pid": 7675, "window_id": 42, + "is_on_screen": True, "title": "FreeCAD", "z_index": 1}, + ] + apps = [ + {"name": "FreeCAD", "bundle_id": "org.freecad.FreeCAD", "pid": 7675}, + ] + backend = _make_cua_backend_with_windows_and_apps(windows, apps) + session = cast(Any, backend._session) + original_call_tool = session.call_tool.side_effect + + def _call_tool(name, args): + if name == "click": + return { + "data": "clicked", "images": [], + "structuredContent": None, "isError": False, + } + return original_call_tool(name, args) + + session.call_tool.side_effect = _call_tool + monkeypatch.setattr(computer_use_tool, "_backend", backend) + + capture_out = computer_use_tool.handle_computer_use({ + "action": "capture", "mode": "ax", "app": "org.freecad.FreeCAD", + }) + assert "error" not in json.loads(capture_out) + click_out = computer_use_tool.handle_computer_use({ + "action": "click", "coordinate": [10, 20], "capture_after": True, + }) + assert "error" not in json.loads(click_out) + + tool_calls = [call.args for call in session.call_tool.call_args_list] + gws_calls = [args for name, args in tool_calls if name == "get_window_state"] + assert len(gws_calls) == 2 + assert all(call["pid"] == 7675 and call["window_id"] == 42 for call in gws_calls) + action_calls = [args for name, args in tool_calls if name == "click"] + assert len(action_calls) == 1 + assert action_calls[0]["window_id"] == 42 + class TestCuaEnvironmentScrubbing: """Verify that cua-driver subprocess environment is sanitized (issue #37878).""" @@ -1985,6 +2477,206 @@ class TestClickButtonPassthrough: assert name == "click" assert args["button"] == "right" assert args["x"] == 10 and args["y"] == 20 + assert args["window_id"] == 222 + + def test_coordinate_drag_and_scroll_keep_the_captured_window(self): + backend = self._backend_with_active_target() + # Mock the capability check so x/y are included (they're gated + # behind the input.scroll.coordinates capability). + backend._session.supports_capability.return_value = True + + backend.drag(from_xy=(10, 20), to_xy=(30, 40)) + drag_name, drag_args = backend._session.call_tool.call_args.args + assert drag_name == "drag" + assert drag_args == { + "pid": 111, + "from_x": 10, + "from_y": 20, + "to_x": 30, + "to_y": 40, + "window_id": 222, + "session": backend._session_id, + } + + backend.scroll(direction="down", x=50, y=60) + scroll_name, scroll_args = backend._session.call_tool.call_args.args + assert scroll_name == "scroll" + assert scroll_args["window_id"] == 222 + assert scroll_args["x"] == 50 and scroll_args["y"] == 60 + + def test_scroll_xy_omitted_without_coordinate_capability(self): + """CUA Driver 0.7.1 Linux schema rejects x/y on scroll. When the + driver does not advertise input.scroll.coordinates, x/y must be + omitted so the call is not rejected.""" + backend = self._backend_with_active_target() + backend._session.supports_capability.return_value = False + + backend.scroll(direction="down", x=50, y=60) + _, scroll_args = backend._session.call_tool.call_args.args + assert "x" not in scroll_args + assert "y" not in scroll_args + assert scroll_args["window_id"] == 222 + + def test_scroll_xy_present_with_coordinate_capability(self): + """When the driver advertises input.scroll.coordinates, x/y are + included in the scroll call.""" + backend = self._backend_with_active_target() + backend._session.supports_capability.return_value = True + + backend.scroll(direction="up", x=10, y=20) + _, scroll_args = backend._session.call_tool.call_args.args + assert scroll_args["x"] == 10 + assert scroll_args["y"] == 20 + assert scroll_args["window_id"] == 222 + + def test_coordinate_actions_without_window_id_fail_closed(self): + backend = self._backend_with_active_target() + backend._active_window_id = None + + assert backend.click(x=10, y=20).ok is False + assert backend.drag(from_xy=(10, 20), to_xy=(30, 40)).ok is False + assert backend.scroll(direction="down", x=10, y=20).ok is False + backend._session.call_tool.assert_not_called() + + +class TestKeyboardWindowIdRouting: + """Review comment #1 on PR #63725: type_text, press_key, and hotkey + must carry window_id so CUA Driver routes input to the correct window + in multi-window apps. Without window_id the driver falls back to the + first window for that PID, which can be the wrong one. + + These tests also verify fail-closed: when _active_window_id is None, + keyboard actions return an error rather than sending PID-only input. + """ + + def _backend_with_active_target(self): + from unittest.mock import MagicMock + from tools.computer_use.cua_backend import CuaDriverBackend + backend = CuaDriverBackend() + backend._session = MagicMock() + backend._session.call_tool.return_value = { + "data": "ok", + "images": [], + "structuredContent": None, + "isError": False, + } + backend._active_pid = 111 + backend._active_window_id = 222 + return backend + + def test_type_text_carries_window_id(self): + backend = self._backend_with_active_target() + backend.type_text("hello world") + name, args = backend._session.call_tool.call_args.args + assert name == "type_text" + assert args["pid"] == 111 + assert args["window_id"] == 222 + assert args["text"] == "hello world" + + def test_press_key_carries_window_id(self): + backend = self._backend_with_active_target() + backend.key("a") + name, args = backend._session.call_tool.call_args.args + assert name == "press_key" + assert args["pid"] == 111 + assert args["window_id"] == 222 + assert args["key"] == "a" + + def test_hotkey_carries_window_id(self): + backend = self._backend_with_active_target() + backend.key("cmd+s") + name, args = backend._session.call_tool.call_args.args + assert name == "hotkey" + assert args["pid"] == 111 + assert args["window_id"] == 222 + assert "cmd" in args["keys"] + assert "s" in args["keys"] + + def test_type_text_fails_closed_without_window_id(self): + backend = self._backend_with_active_target() + backend._active_window_id = None + res = backend.type_text("hello") + assert res.ok is False + backend._session.call_tool.assert_not_called() + + def test_press_key_fails_closed_without_window_id(self): + backend = self._backend_with_active_target() + backend._active_window_id = None + res = backend.key("a") + assert res.ok is False + backend._session.call_tool.assert_not_called() + + def test_hotkey_fails_closed_without_window_id(self): + backend = self._backend_with_active_target() + backend._active_window_id = None + res = backend.key("cmd+s") + assert res.ok is False + backend._session.call_tool.assert_not_called() + + +class TestZIndexSorting: + """Review comment #3 on PR #63725: CUA Driver defines higher z_index + values as closer to the front (top of the stack). The wrapper must sort + descending so the frontmost window is selected. Wayland may return + z_index: null, which must be handled without crashing. + """ + + def test_frontmost_window_selected_by_higher_z_index(self): + """The frontmost window (highest z_index) should be the one + capture() selects as its target.""" + from tools.computer_use.cua_backend import CuaDriverBackend + + windows = [ + {"app_name": "Terminal", "pid": 100, "window_id": 1, + "is_on_screen": True, "title": "term", "z_index": 5}, + {"app_name": "Firefox", "pid": 200, "window_id": 2, + "is_on_screen": True, "title": "browser", "z_index": 10}, + {"app_name": "Desktop", "pid": 300, "window_id": 3, + "is_on_screen": True, "title": "desktop", "z_index": 0}, + ] + backend = _make_cua_backend_with_windows(windows) + + cap = backend.capture(mode="ax") + + # Firefox has z_index=10 (frontmost) — must be selected. + assert backend._active_pid == 200 + assert backend._active_window_id == 2 + + def test_null_z_index_treated_as_lowest(self): + """Wayland may return z_index: null. _ingest_windows must coerce + it to 0 (backmost) so it doesn't crash the sort or get selected + over real foreground windows.""" + from tools.computer_use.cua_backend import _ingest_windows + + raw = [ + {"app_name": "Desktop", "pid": 300, "window_id": 3, + "is_on_screen": True, "title": "desktop", "z_index": None}, + {"app_name": "Firefox", "pid": 200, "window_id": 2, + "is_on_screen": True, "title": "browser", "z_index": 5}, + ] + out = _ingest_windows(raw) + # Both windows survive (null z_index doesn't drop the window). + assert len(out) == 2 + # Null z_index was normalised to 0. + desktop = next(w for w in out if w["app_name"] == "Desktop") + assert desktop["z_index"] == 0 + + def test_null_z_index_does_not_crash_capture(self): + """End-to-end: a null z_index window in the list must not crash + capture(), and a real window (with z_index) must be selected over + the null-z_index desktop.""" + windows = [ + {"app_name": "Desktop", "pid": 300, "window_id": 3, + "is_on_screen": True, "title": "desktop", "z_index": None}, + {"app_name": "Firefox", "pid": 200, "window_id": 2, + "is_on_screen": True, "title": "browser", "z_index": 5}, + ] + backend = _make_cua_backend_with_windows(windows) + + cap = backend.capture(mode="ax") + + assert backend._active_pid == 200 + assert backend._active_window_id == 2 class TestImageMimeTypePropagation: diff --git a/tests/tools/test_config_null_guard.py b/tests/tools/test_config_null_guard.py index cb80ab8ecf57..30b63c783da0 100644 --- a/tests/tools/test_config_null_guard.py +++ b/tests/tools/test_config_null_guard.py @@ -21,7 +21,7 @@ class TestTTSProviderNullGuard: assert result == DEFAULT_PROVIDER.lower().strip() def test_missing_provider_returns_default(self): - """No ``provider`` key at all should also return default.""" + """No ``provider`` key + non-TTS active provider should return default.""" from tools.tts_tool import _get_provider, DEFAULT_PROVIDER result = _get_provider({}) @@ -33,6 +33,27 @@ class TestTTSProviderNullGuard: result = _get_provider({"provider": "OPENAI"}) assert result == "openai" + def test_missing_provider_keeps_free_default_with_cloud_credentials(self): + """A chat-provider key must not silently opt the user into paid TTS.""" + from tools.tts_tool import _get_provider, DEFAULT_PROVIDER + + assert _get_provider({}) == DEFAULT_PROVIDER + assert _get_provider({"provider": None}) == DEFAULT_PROVIDER + + def test_active_provider_without_credentials_keeps_edge(self): + """A TTS-capable active provider that can't authenticate must NOT + silently displace the free Edge default (no surprise billing / hard + errors for a credential-less deployment).""" + from tools.tts_tool import _get_provider, DEFAULT_PROVIDER + + assert _get_provider({}) == DEFAULT_PROVIDER.lower().strip() + + def test_explicit_provider_wins_over_active(self): + """An explicit tts.provider always overrides the active-provider fallback.""" + from tools.tts_tool import _get_provider + + assert _get_provider({"provider": "edge"}) == "edge" + # ── Web tools ───────────────────────────────────────────────────────────── diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 43c8089f1127..0fe6e0028eb6 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -1265,6 +1265,24 @@ class TestDelegationCredentialResolution(unittest.TestCase): requested="crof.ai", target_model="deepseek-v4-pro-CEER" ) + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + def test_provider_forwards_runtime_request_overrides_and_output_cap(self, mock_resolve): + mock_resolve.return_value = { + "provider": "custom", + "model": "real-model", + "base_url": "https://gateway.example/v1", + "api_key": "gateway-key", + "api_mode": "chat_completions", + "request_overrides": {"extra_body": {"store": False}}, + "max_output_tokens": 3072, + } + creds = _resolve_delegation_credentials( + {"model": "real-model", "provider": "gateway"}, + _make_mock_parent(depth=0), + ) + self.assertEqual(creds["request_overrides"], {"extra_body": {"store": False}}) + self.assertEqual(creds["max_output_tokens"], 3072) + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") def test_standard_provider_not_overwritten_by_configured_name(self, mock_resolve): """Standard (non-custom) providers must still return runtime identity, @@ -1446,6 +1464,8 @@ class TestDelegationProviderIntegration(unittest.TestCase): parent.providers_ignored = ["openai/gpt-4o-mini"] parent.providers_order = ["google/gemini-2.5-pro"] parent.provider_sort = "price" + parent.provider_require_parameters = True + parent.provider_data_collection = "deny" with patch("run_agent.AIAgent") as MockAgent: mock_child = MagicMock() @@ -1464,6 +1484,44 @@ class TestDelegationProviderIntegration(unittest.TestCase): self.assertIsNone(kwargs["providers_ignored"]) self.assertIsNone(kwargs["providers_order"]) self.assertIsNone(kwargs["provider_sort"]) + self.assertIs(kwargs["provider_require_parameters"], False) + self.assertEqual(kwargs["provider_data_collection"], "") + + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_same_provider_inherits_all_routing_preferences(self, mock_creds, mock_cfg): + mock_cfg.return_value = {"max_iterations": 45} + mock_creds.return_value = { + "model": None, + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + parent = _make_mock_parent(depth=0) + parent.provider = "nous" + parent.providers_allowed = ["deepseek"] + parent.providers_ignored = ["deepinfra"] + parent.providers_order = ["anthropic"] + parent.provider_sort = "throughput" + parent.provider_require_parameters = True + parent.provider_data_collection = "deny" + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.run_conversation.return_value = { + "final_response": "done", "completed": True, "api_calls": 1 + } + MockAgent.return_value = mock_child + delegate_task(goal="Keep routing", parent_agent=parent) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["providers_allowed"], ["deepseek"]) + self.assertEqual(kwargs["providers_ignored"], ["deepinfra"]) + self.assertEqual(kwargs["providers_order"], ["anthropic"]) + self.assertEqual(kwargs["provider_sort"], "throughput") + self.assertIs(kwargs["provider_require_parameters"], True) + self.assertEqual(kwargs["provider_data_collection"], "deny") @patch("tools.delegate_tool._load_config") @patch("tools.delegate_tool._resolve_delegation_credentials") diff --git a/tests/tools/test_discord_send_message_caption.py b/tests/tools/test_discord_send_message_caption.py new file mode 100644 index 000000000000..02e102904fd5 --- /dev/null +++ b/tests/tools/test_discord_send_message_caption.py @@ -0,0 +1,133 @@ +"""Discord standalone MEDIA: caption delivery. + +When `hermes send --to discord "MEDIA:/x.png This Caption"` targets a normal +(non-forum) channel, the caption must ride on the media message content rather +than being posted as a separate message before the attachment. The Discord REST +calls are mocked at the aiohttp.ClientSession boundary. +""" + +import asyncio +import json +import os +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +from plugins.platforms.discord.adapter import _remember_channel_is_forum, _standalone_send + + +def _resp(status, json_data=None, text_data=None): + r = AsyncMock() + r.status = status + body = json.dumps(json_data or {}).encode() if json_data is not None else (text_data or "").encode() + r.json = AsyncMock(return_value=json_data or {}) + r.text = AsyncMock(return_value=text_data or "") + # Discord's _standalone_read_*_limited helpers stream resp.content.read(); + # return the body once then EOF so the bounded reader terminates. AsyncMock + # with a list side_effect yields each element on successive awaits. + r.content = MagicMock() + r.content.read = AsyncMock(side_effect=[body, b"", b""]) + # _standalone_response_encoding calls resp.get_encoding() expecting a str; + # a bare AsyncMock would return a coroutine. Give it a plain callable. + r.get_encoding = MagicMock(return_value="utf-8") + return r + + +def _session_with(responses): + """Mocked aiohttp.ClientSession recording every POST (url, json, data).""" + calls = [] + idx = [0] + + def _post(url, **kwargs): + calls.append((url, kwargs.get("json"), kwargs.get("data"))) + r = responses[idx[0]] if idx[0] < len(responses) else responses[-1] + idx[0] += 1 + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=r) + ctx.__aexit__ = AsyncMock(return_value=False) + return ctx + + session = MagicMock() + session.post = MagicMock(side_effect=_post) + session_ctx = MagicMock() + session_ctx.__aenter__ = AsyncMock(return_value=session) + session_ctx.__aexit__ = AsyncMock(return_value=False) + return session_ctx, calls + + +def _pconfig(): + return SimpleNamespace(token="bot-token", extra={}) + + +def _tmpfile(suffix): + f = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) + f.write(b"x") + f.close() + return f.name + + +def _payload_json_content(form_data): + """Extract the 'content' from a FormData's payload_json field, if any.""" + for field in getattr(form_data, "_fields", []): + # aiohttp FormData stores (type_options_dict, headers, value) + try: + type_opts = field[0] + value = field[2] + except (IndexError, TypeError): + continue + if type_opts.get("name") == "payload_json": + return json.loads(value).get("content") + return None + + +def test_caption_rides_media_non_forum(): + chat_id = "999000111" + _remember_channel_is_forum(chat_id, False) # avoid the live GET probe + img = _tmpfile(".png") + try: + session_ctx, calls = _session_with([_resp(200, {"id": "m1"})]) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + chat_id, + "", + media_files=[(img, False)], + caption="2-bedroom floor plan", + ) + ) + assert res["success"] is True + # Exactly one POST (the media upload) — no separate text message. + assert len(calls) == 1 + url, _json, data = calls[0] + assert url.endswith("/messages") + assert _payload_json_content(data) == "2-bedroom floor plan" + finally: + os.unlink(img) + + +def test_no_caption_non_forum_keeps_separate_text(): + """Without a caption, text + media are two separate POSTs (unchanged).""" + chat_id = "999000222" + _remember_channel_is_forum(chat_id, False) + img = _tmpfile(".png") + try: + session_ctx, calls = _session_with( + [_resp(200, {"id": "t1"}), _resp(200, {"id": "m1"})] + ) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + chat_id, + "hello", + media_files=[(img, False)], + ) + ) + assert res["success"] is True + # Two POSTs: the text content message, then the media upload. + assert len(calls) == 2 + assert calls[0][1] == {"content": "hello"} + assert calls[1][0].endswith("/messages") + finally: + os.unlink(img) diff --git a/tests/tools/test_execute_code_approval_cluster.py b/tests/tools/test_execute_code_approval_cluster.py index c5d7f3fb78c9..7ea74a53b438 100644 --- a/tests/tools/test_execute_code_approval_cluster.py +++ b/tests/tools/test_execute_code_approval_cluster.py @@ -17,6 +17,7 @@ from __future__ import annotations import concurrent.futures import contextvars +import json import threading import pytest @@ -111,8 +112,10 @@ def gw_session(monkeypatch): monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) - # Force manual mode regardless of host config. + # Force manual mode regardless of host config and disable any process-level + # yolo inherited from the developer's live environment. monkeypatch.setattr(A, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", False) session_key = "cluster-test-session" token = A.set_current_session_key(session_key) @@ -142,6 +145,23 @@ def _register_resolver(session_key: str, result): A._gateway_notify_cbs[session_key] = cb +def _register_capturing_resolver(session_key: str, result): + """Resolve immediately and retain the exact approval payload shown.""" + seen = {} + + def cb(approval_data): + seen["approval_data"] = approval_data + with A._lock: + entries = A._gateway_queues.get(session_key, []) + if entries: + entries[-1].result = result + entries[-1].event.set() + + with A._lock: + A._gateway_notify_cbs[session_key] = cb + return seen + + def test_guard_isolated_backend_approved(): # Container backends already sandbox the child — no-op approve. assert A.check_execute_code_guard("import os", "docker")["approved"] is True @@ -158,6 +178,7 @@ def test_guard_headless_local_approved(monkeypatch): def test_guard_cron_deny_blocks(monkeypatch): + monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", False) monkeypatch.setenv("HERMES_CRON_SESSION", "1") monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) monkeypatch.setattr(A, "_get_approval_mode", lambda: "manual") @@ -231,11 +252,21 @@ def test_guard_gateway_user_denies_blocks(gw_session): assert res["user_consent"] is False -def test_guard_gateway_timeout_blocks(gw_session, monkeypatch): +@pytest.mark.parametrize( + "approval_config", + [ + {"timeout": 0}, + {"timeout": 0, "gateway_timeout": 300}, + ], + ids=["shared-timeout-only", "shared-timeout-is-canonical"], +) +def test_guard_gateway_wait_uses_canonical_timeout( + gw_session, monkeypatch, approval_config +): # Register a callback that never resolves; force an immediate timeout. with A._lock: A._gateway_notify_cbs[gw_session] = lambda _d: None - monkeypatch.setattr(A, "_get_approval_config", lambda: {"gateway_timeout": 0}) + monkeypatch.setattr(A, "_get_approval_config", lambda: approval_config) res = A.check_execute_code_guard("import os", "local") assert res["approved"] is False assert res["outcome"] == "timeout" @@ -255,9 +286,11 @@ def test_guard_smart_mode(gw_session, monkeypatch): res = A.check_execute_code_guard("import os", "local") assert res["approved"] is True and res.get("smart_approved") is True + # Smart DENY on an interactive surface now asks the owner. With no bound + # notifier it remains pending rather than being hard-denied. monkeypatch.setattr(A, "_smart_approve", lambda c, d: "deny") res = A.check_execute_code_guard("import os", "local") - assert res["approved"] is False and res.get("smart_denied") is True + assert res["approved"] is False and res["status"] == "pending_approval" # escalate → falls through to manual gateway approval monkeypatch.setattr(A, "_smart_approve", lambda c, d: "escalate") @@ -266,6 +299,150 @@ def test_guard_smart_mode(gw_session, monkeypatch): assert res["approved"] is True +def test_terminal_smart_deny_owner_override_is_one_operation(gw_session, monkeypatch): + """A human may override DENY, but a broad UI choice must not be persisted.""" + with A._lock: + A._permanent_approved.discard("owner-override-test-danger") + A._session_approved.get(gw_session, set()).discard("owner-override-test-danger") + monkeypatch.setattr(A, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(A, "_smart_approve", lambda _command, _description: "deny") + monkeypatch.setattr( + A, + "detect_dangerous_command", + lambda command: (True, "owner-override-test-danger", f"risk:{command}"), + ) + monkeypatch.setattr( + "tools.tirith_security.check_command_security", + lambda _command: {"action": "allow", "findings": [], "summary": ""}, + raising=False, + ) + + shown = _register_capturing_resolver(gw_session, "always") + result = A.check_all_command_guards("dangerous /tmp/first", "local") + + assert result["approved"] is True + assert result["user_approved"] is True + assert shown["approval_data"]["smart_denied"] is True + assert shown["approval_data"]["allow_permanent"] is False + assert A.is_approved(gw_session, "owner-override-test-danger") is False + + _register_resolver(gw_session, "deny") + changed = A.check_all_command_guards("dangerous /tmp/second", "local") + assert changed["approved"] is False + assert changed["outcome"] == "denied" + + +def test_execute_code_smart_deny_owner_override_is_one_operation(gw_session, monkeypatch): + """Never persist the coarse execute_code key after overriding smart DENY.""" + with A._lock: + A._permanent_approved.discard("execute_code") + A._session_approved.get(gw_session, set()).discard("execute_code") + monkeypatch.setattr(A, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(A, "_smart_approve", lambda _command, _description: "deny") + + shown = _register_capturing_resolver(gw_session, "session") + result = A.check_execute_code_guard("print('first')", "local") + + assert result["approved"] is True + assert result["user_approved"] is True + assert shown["approval_data"]["smart_denied"] is True + assert shown["approval_data"]["allow_permanent"] is False + assert A.is_approved(gw_session, "execute_code") is False + + _register_resolver(gw_session, "deny") + changed = A.check_execute_code_guard("print('second')", "local") + assert changed["approved"] is False + assert changed["outcome"] == "denied" + + +def test_smart_escalate_still_persists_session_choice(gw_session, monkeypatch): + """The DENY restriction must not alter Smart ESCALATE's manual choices.""" + key = "smart-escalate-persistence" + with A._lock: + A._session_approved.get(gw_session, set()).discard(key) + monkeypatch.setattr(A, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(A, "_smart_approve", lambda _command, _description: "escalate") + monkeypatch.setattr( + A, "detect_dangerous_command", + lambda command: (True, key, f"risk:{command}"), + ) + monkeypatch.setattr( + "tools.tirith_security.check_command_security", + lambda _command: {"action": "allow", "findings": [], "summary": ""}, + raising=False, + ) + + shown = _register_capturing_resolver(gw_session, "session") + result = A.check_all_command_guards("dangerous escalate", "local") + + assert result["approved"] is True + assert shown["approval_data"]["allow_permanent"] is True + assert "smart_denied" not in shown["approval_data"] + assert A.is_approved(gw_session, key) is True + + +def test_terminal_smart_deny_pending_payload_is_one_operation(gw_session, monkeypatch): + monkeypatch.setattr(A, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(A, "_smart_approve", lambda _command, _description: "deny") + monkeypatch.setattr( + A, "detect_dangerous_command", + lambda command: (True, "pending-smart-deny", f"risk:{command}"), + ) + monkeypatch.setattr( + "tools.tirith_security.check_command_security", + lambda _command: {"action": "allow", "findings": [], "summary": ""}, + raising=False, + ) + + result = A.check_all_command_guards("dangerous pending", "local") + + assert result["status"] == "pending_approval" + assert result["smart_denied"] is True + assert result["allow_permanent"] is False + with A._lock: + pending = dict(A._pending[gw_session]) + assert pending["smart_denied"] is True + assert pending["allow_permanent"] is False + + +def test_execute_code_smart_deny_pending_payload_is_one_operation(gw_session, monkeypatch): + monkeypatch.setattr(A, "_get_approval_mode", lambda: "smart") + monkeypatch.setattr(A, "_smart_approve", lambda _command, _description: "deny") + + result = A.check_execute_code_guard("print('pending')", "local") + + assert result["status"] == "pending_approval" + assert result["smart_denied"] is True + assert result["allow_permanent"] is False + with A._lock: + pending = dict(A._pending[gw_session]) + assert pending["smart_denied"] is True + assert pending["allow_permanent"] is False + + +def test_terminal_serializes_smart_deny_pending_capabilities(monkeypatch): + from tools import terminal_tool as terminal_module + + monkeypatch.setattr( + terminal_module, + "_check_all_guards", + lambda *_args, **_kwargs: { + "approved": False, + "status": "pending_approval", + "command": "rm -rf /tmp/example", + "description": "recursive delete", + "pattern_key": "rm-rf", + "smart_denied": True, + "allow_permanent": False, + }, + ) + + payload = json.loads(terminal_module.terminal_tool(command="rm -rf /tmp/example")) + + assert payload["smart_denied"] is True + assert payload["allow_permanent"] is False + + def test_guard_session_yolo_bypasses(gw_session): A.enable_session_yolo(gw_session) try: @@ -333,6 +510,7 @@ def test_execute_code_entry_blocks_before_spawn_when_guard_denies(monkeypatch, t from tools import terminal_tool as TT marker = tmp_path / "child-ran.marker" + monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", False) monkeypatch.setenv("HERMES_CRON_SESSION", "1") monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) diff --git a/tests/tools/test_file_operations.py b/tests/tools/test_file_operations.py index eda5140b0163..3b2c0ce59322 100644 --- a/tests/tools/test_file_operations.py +++ b/tests/tools/test_file_operations.py @@ -467,6 +467,61 @@ class TestShellFileOpsHelpers: # Should be safely escaped assert result.count("'") >= 4 # wrapping + escaping + def test_escape_shell_arg_rewrites_windows_drive_paths_to_msys(self, monkeypatch, file_ops): + # bash eats backslashes and MSYS mangles ``C:\...``; the Git Bash + # ``/c/...`` form is the reliable one (reuses _windows_to_msys_path). + import tools.environments.local as local_mod + + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert file_ops._escape_shell_arg(r"C:\Users\alice\notes.txt") == "'/c/Users/alice/notes.txt'" + # Non-drive paths are untouched. + assert file_ops._escape_shell_arg("/tmp/foo") == "'/tmp/foo'" + + def test_escape_shell_arg_normalizes_mixed_msys_paths(self, monkeypatch, file_ops): + import tools.environments.local as local_mod + + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + mixed = r"/c/Users/Alexander\Documents\NewTEST\readme.txt" + assert file_ops._escape_shell_arg(mixed) == ( + "'/c/Users/Alexander/Documents/NewTEST/readme.txt'" + ) + + def test_escape_shell_arg_rewrites_forward_slash_native_paths(self, monkeypatch, file_ops): + import tools.environments.local as local_mod + + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert file_ops._escape_shell_arg( + "C:/Users/alice/notes.txt" + ) == "'/c/Users/alice/notes.txt'" + + def test_read_file_uses_bash_safe_windows_paths(self, mock_env, monkeypatch): + import tools.environments.local as local_mod + + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + commands = [] + + def side_effect(command, **kwargs): + commands.append(command) + if command.startswith("wc -c"): + return {"output": "5\n", "returncode": 0} + if command.startswith("head -c"): + return {"output": "hello", "returncode": 0} + if command.startswith("sed -n"): + return {"output": "hello\n", "returncode": 0} + if command.startswith("wc -l"): + return {"output": "1\n", "returncode": 0} + return {"output": "", "returncode": 0} + + mock_env.execute.side_effect = side_effect + ops = ShellFileOperations(mock_env) + result = ops.read_file(r"C:\Users\alice\notes.txt") + + assert result.error is None + assert commands[0] == "wc -c < '/c/Users/alice/notes.txt' 2>/dev/null" + assert commands[1] == "head -c 1000 '/c/Users/alice/notes.txt' 2>/dev/null" + assert commands[2] == "sed -n '1,500p' '/c/Users/alice/notes.txt'" + assert commands[3] == "wc -l < '/c/Users/alice/notes.txt'" + def test_is_likely_binary_by_extension(self, file_ops): assert file_ops._is_likely_binary("photo.png") is True assert file_ops._is_likely_binary("data.db") is True diff --git a/tests/tools/test_file_tools.py b/tests/tools/test_file_tools.py index 36918417938e..f57d52821037 100644 --- a/tests/tools/test_file_tools.py +++ b/tests/tools/test_file_tools.py @@ -427,6 +427,69 @@ class TestSearchHandler: assert "error" in result +# --------------------------------------------------------------------------- +# Windows MSYS path resolution (salvage of #50488 / #46995) +# --------------------------------------------------------------------------- + +class TestWindowsMsysPathResolution: + """File tools must translate Git Bash drive paths before Path resolution.""" + + def test_absolute_msys_path_normalized_before_windows_resolve(self, monkeypatch): + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": False) + + resolved = file_tools._resolve_path_for_task("/c/Users/Mark/project/app.py") + assert str(resolved) == r"C:\Users\Mark\project\app.py" + + def test_cygdrive_path_normalized(self, monkeypatch): + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": False) + + resolved = file_tools._resolve_path_for_task("/cygdrive/d/code/main.py") + assert str(resolved) == r"D:\code\main.py" + + def test_relative_path_uses_normalized_msys_cwd(self, monkeypatch): + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": False) + monkeypatch.setattr( + file_tools, + "_authoritative_workspace_root", + lambda task_id="default": "/c/Users/Mark/project", + ) + + resolved = file_tools._resolve_path_for_task("src/app.py", task_id="msys") + assert str(resolved) == r"C:\Users\Mark\project\src\app.py" + + def test_container_paths_skip_msys_translation(self, monkeypatch): + """WSL/docker Linux paths must not be rewritten as Windows drives.""" + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": True) + monkeypatch.setattr( + file_tools, + "_authoritative_workspace_root", + lambda task_id="default": "/home/don/project", + ) + + resolved = file_tools._resolve_path_for_task("/home/don/.env") + assert str(resolved) == "/home/don/.env" + + # --------------------------------------------------------------------------- # Tool result hint tests (#722) # --------------------------------------------------------------------------- diff --git a/tests/tools/test_file_write_safety.py b/tests/tools/test_file_write_safety.py index ae766a7a723e..7cf5c0468f6d 100644 --- a/tests/tools/test_file_write_safety.py +++ b/tests/tools/test_file_write_safety.py @@ -168,6 +168,107 @@ class TestMultipleSafeWriteRoots: assert _is_write_denied(str(inside)) is False +class TestGetWriteDeniedError: + """get_write_denied_error() should distinguish credential vs safe-root blocks.""" + + def test_credential_path_message(self): + from agent.file_safety import get_write_denied_error + + err = get_write_denied_error(os.path.expanduser("~/.ssh/id_rsa")) + assert err is not None + assert "protected system/credential file" in err + assert "HERMES_WRITE_SAFE_ROOT" not in err + + def test_safe_root_message(self, tmp_path: Path, monkeypatch): + from agent.file_safety import get_write_denied_error + + safe_root = tmp_path / "workspace" + outside = tmp_path / "outside.txt" + os.makedirs(safe_root, exist_ok=True) + + monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root)) + err = get_write_denied_error(str(outside)) + assert err is not None + assert "outside HERMES_WRITE_SAFE_ROOT" in err + assert str(safe_root) in err + assert "protected system/credential file" not in err + + def test_allowed_path_returns_none(self, tmp_path: Path): + from agent.file_safety import get_write_denied_error + + target = tmp_path / "ok.txt" + assert get_write_denied_error(str(target)) is None + + +class TestSafeRootDenialMessageIntegration: + """Regression tests verifying that file-tools surface the correct denial + message when HERMES_WRITE_SAFE_ROOT blocks a path. + + Prior to this fix, ALL write denials returned the same "protected + system/credential file" message regardless of root cause. These tests + exercise the actual write_file / patch_replace code path, not just + the get_write_denied_error() helper in isolation. + """ + + @pytest.fixture + def ops(self, tmp_path: Path): + from tools.environments.local import LocalEnvironment + from tools.file_operations import ShellFileOperations + env = LocalEnvironment(cwd=str(tmp_path)) + return ShellFileOperations(env, cwd=str(tmp_path)) + + def test_write_file_safe_root_outside_shows_safe_root_message( + self, ops, tmp_path: Path, monkeypatch + ): + safe_root = tmp_path / "workspace" + safe_root.mkdir() + outside = tmp_path / "other" / "file.txt" + outside.parent.mkdir() + monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root)) + + res = ops.write_file(str(outside), "content") + assert res.error is not None + assert "outside HERMES_WRITE_SAFE_ROOT" in res.error + assert str(safe_root) in res.error + assert "credential" not in res.error + assert not outside.exists() + + def test_patch_replace_safe_root_outside_shows_safe_root_message( + self, ops, tmp_path: Path, monkeypatch + ): + safe_root = tmp_path / "workspace" + safe_root.mkdir() + outside = tmp_path / "other" / "file.txt" + outside.parent.mkdir() + outside.write_text("old content") + monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root)) + + res = ops.patch_replace(str(outside), "old", "new") + assert res.error is not None + assert "outside HERMES_WRITE_SAFE_ROOT" in res.error + assert "credential" not in res.error + + def test_write_file_credential_path_shows_credential_message( + self, ops, tmp_path: Path + ): + res = ops.write_file("/etc/shadow", "content") + assert res.error is not None + assert "protected system/credential file" in res.error + assert "outside" not in res.error + + def test_write_file_allowed_path_returns_no_error( + self, ops, tmp_path: Path, monkeypatch + ): + safe_root = tmp_path / "workspace" + safe_root.mkdir() + inside = safe_root / "file.txt" + monkeypatch.setenv("HERMES_WRITE_SAFE_ROOT", str(safe_root)) + + res = ops.write_file(str(inside), "content") + assert res.error is None + assert inside.read_text() == "content" + + class TestCheckSensitivePathMacOSBypass: """Verify _check_sensitive_path blocks /private/etc paths (issue #8734).""" diff --git a/tests/tools/test_find_shell.py b/tests/tools/test_find_shell.py index 6de3b2594f90..b4bce6003150 100644 --- a/tests/tools/test_find_shell.py +++ b/tests/tools/test_find_shell.py @@ -116,6 +116,33 @@ class TestFindBashUnchanged: assert len(result) > 0 +class TestFindBashSkipsBrokenCustomPath: + """Stale HERMES_GIT_BASH_PATH must not brick Windows terminal startup.""" + + def test_falls_through_to_portable_when_custom_fails_probe(self, tmp_path, monkeypatch): + import tools.environments.local as local_mod + + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + local_mod._bash_starts_cache.clear() + + broken = tmp_path / "broken" / "bash.exe" + broken.parent.mkdir() + broken.write_text("", encoding="utf-8") + portable = tmp_path / "hermes" / "git" / "bin" / "bash.exe" + portable.parent.mkdir(parents=True) + portable.write_text("", encoding="utf-8") + + monkeypatch.setenv("HERMES_GIT_BASH_PATH", str(broken)) + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + + def fake_starts(path: str) -> bool: + return path == str(portable) + + monkeypatch.setattr(local_mod, "_bash_starts", fake_starts) + + assert _find_bash() == str(portable) + + @pytest.mark.skipif( not os.path.isfile("/bin/bash") or sys.platform != "darwin", reason="reproduces the macOS system-bash-3.2 login-shell swallow", diff --git a/tests/tools/test_image_generation_plugin_dispatch.py b/tests/tools/test_image_generation_plugin_dispatch.py index fa8ca9d959c9..f96da8d64df4 100644 --- a/tests/tools/test_image_generation_plugin_dispatch.py +++ b/tests/tools/test_image_generation_plugin_dispatch.py @@ -97,3 +97,28 @@ class TestPluginDispatch: assert payload["success"] is True assert payload["provider"] == "codex" assert payload["aspect_ratio"] == "portrait" + + def test_unset_provider_keeps_legacy_fal_path(self, monkeypatch): + """An unrelated API key must not opt the user into paid image generation.""" + from tools import image_generation_tool + + monkeypatch.setattr(image_generation_tool, "_read_configured_image_provider", lambda: None) + assert image_generation_tool._dispatch_to_plugin_provider("draw cat", "landscape") is None + + def test_deepinfra_key_alone_does_not_select_image_backend(self, monkeypatch): + """DeepInfra chat credentials do not imply consent to image billing.""" + from tools import image_generation_tool + + monkeypatch.setenv("DEEPINFRA_API_KEY", "«redacted:sk-…»") + monkeypatch.delenv("FAL_KEY", raising=False) + monkeypatch.setattr(image_generation_tool, "_read_configured_image_provider", lambda: None) + assert image_generation_tool._dispatch_to_plugin_provider("a cat", "square") is None + + def test_requirements_ignore_unselected_paid_plugin(self, monkeypatch): + from tools import image_generation_tool + + monkeypatch.setattr(image_generation_tool, "check_fal_api_key", lambda: False) + monkeypatch.setattr( + image_generation_tool, "_read_configured_image_provider", lambda: None + ) + assert image_generation_tool.check_image_generation_requirements() is False diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index cdf8c4cdb5da..5b1d9f6f7556 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -484,6 +484,31 @@ def test_complete_rejects_non_list_artifacts(worker_env): assert "artifacts must be a list" in err +def test_complete_missing_scratch_artifact_stays_in_flight(worker_env): + """A false deliverable claim must return retry guidance, not mark Done.""" + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + with kb.connect() as conn: + task = kb.get_task(conn, worker_env) + assert task is not None + workspace = kb.resolve_workspace(task) + kb.set_workspace_path(conn, worker_env, workspace) + + output = kt._handle_complete({ + "summary": "report complete", + "artifacts": [str(workspace / "missing-report.md")], + }) + error = json.loads(output).get("error", "") + + assert "could not preserve" in error + assert "still in-flight" in error + assert "retry kanban_complete" in error + with kb.connect() as conn: + assert kb.get_task(conn, worker_env).status == "running" + assert workspace.exists() + + def test_complete_rejects_no_handoff(worker_env): from tools import kanban_tools as kt out = kt._handle_complete({}) diff --git a/tests/tools/test_local_background_child_hang.py b/tests/tools/test_local_background_child_hang.py index 2ed8c575c695..dd9f112a5678 100644 --- a/tests/tools/test_local_background_child_hang.py +++ b/tests/tools/test_local_background_child_hang.py @@ -91,6 +91,69 @@ class TestBackgroundChildDoesNotHang: assert lines[0] == "1" assert lines[-1] == "3000" + def test_foreground_capture_is_bounded_while_draining( + self, local_env, monkeypatch + ): + monkeypatch.setattr("tools.tool_output_limits.get_max_bytes", lambda: 10_000) + command = ( + "python3 -c \"import sys; " + "sys.stdout.write('HEAD-SENTINEL\\n' + 'x' * 2000000 + " + "'\\nTAIL-SENTINEL')\"" + ) + + result = local_env.execute(command, timeout=10, bounded_capture=True) + + assert result["returncode"] == 0 + assert len(result["output"]) <= 10_000 + assert result["output"].startswith("HEAD-SENTINEL") + assert result["output"].endswith("TAIL-SENTINEL") + assert "[OUTPUT TRUNCATED" in result["output"] + + def test_default_capture_is_full_fidelity_for_internal_consumers( + self, local_env + ): + """Default execute() (no bounded_capture) must return complete output. + + Internal consumers — file-operation ``cat`` reads that feed the patch + engine, code-execution RPC reads, log reads — rely on full-fidelity + capture. Bounding them at tool_output.max_bytes would CORRUPT files + on read-modify-write (#64435 review finding), so only the foreground + terminal tool opts in via bounded_capture=True. + """ + # ~200 KB — four times the default 50 KB cap. + command = ( + "python3 -c \"import sys; " + "sys.stdout.write('START-MARK\\n' + ('y' * 200000) + '\\nEND-MARK')\"" + ) + + result = local_env.execute(command, timeout=10) + + assert result["returncode"] == 0 + assert "[OUTPUT TRUNCATED" not in result["output"] + assert result["output"].startswith("START-MARK") + assert result["output"].endswith("END-MARK") + assert len(result["output"]) > 200000 + + def test_continuous_output_still_honors_foreground_timeout( + self, local_env, monkeypatch + ): + monkeypatch.setattr("tools.tool_output_limits.get_max_bytes", lambda: 5_000) + command = ( + "python3 -c \"import sys; " + "chunk = 'x' * 4096; " + "exec('while True: sys.stdout.write(chunk); sys.stdout.flush()')\"" + ) + + started = time.monotonic() + result = local_env.execute(command, timeout=1, bounded_capture=True) + elapsed = time.monotonic() - started + + assert elapsed < 4.0 + assert result["returncode"] == 124 + assert len(result["output"]) <= 5_000 + assert "[OUTPUT TRUNCATED" in result["output"] + assert result["output"].endswith("[Command timed out after 1s]") + def test_timeout_path_still_works(self, local_env): """Foreground command exceeding timeout must still be killed.""" t0 = time.monotonic() diff --git a/tests/tools/test_local_env_windows_msys.py b/tests/tools/test_local_env_windows_msys.py index 59f01ac56b6b..f5eba3f252a8 100644 --- a/tests/tools/test_local_env_windows_msys.py +++ b/tests/tools/test_local_env_windows_msys.py @@ -18,14 +18,19 @@ and ``os.path.isdir`` so the MSYS path tests as "missing" exactly like on the real OS. """ +import os from unittest.mock import patch - +from tools.environments.base import BaseEnvironment from tools.environments import local as local_mod from tools.environments.local import ( LocalEnvironment, + _bash_safe_path, + _git_bash_bin_dirs, _make_run_env, _msys_to_windows_path, + _prepend_git_bash_dirs, + _quote_bash_path, _resolve_safe_cwd, _sanitize_subprocess_env, _windows_to_msys_path, @@ -68,6 +73,15 @@ class TestMsysToWindowsPath: monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) assert _msys_to_windows_path("/tmp/foo") == "/tmp/foo" assert _msys_to_windows_path("/home/x") == "/home/x" + # /mnt//... only translates when is a single drive letter. + assert _msys_to_windows_path("/mnt/home/x") == "/mnt/home/x" + + def test_translates_cygdrive_and_wsl_mnt_forms(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert _msys_to_windows_path("/cygdrive/c/Users/NVIDIA") == r"C:\Users\NVIDIA" + assert _msys_to_windows_path("/mnt/d/Projects/foo") == r"D:\Projects\foo" + assert _msys_to_windows_path("/cygdrive/c") == "C:\\" + assert _msys_to_windows_path("/mnt/c/") == "C:\\" def test_empty_string(self, monkeypatch): monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) @@ -103,6 +117,42 @@ class TestWindowsToMsysPath: assert _windows_to_msys_path(r"\\server\share") == r"\\server\share" +# --------------------------------------------------------------------------- +# _bash_safe_path / _quote_bash_path — shell-script interpolation +# --------------------------------------------------------------------------- + +class TestBashSafePath: + def test_native_windows_path_becomes_msys(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert _bash_safe_path(r"C:\Users\alice\notes.txt") == "/c/Users/alice/notes.txt" + + def test_forward_slash_native_path_becomes_msys(self, monkeypatch): + """Production get_temp_dir emits C:/... — still needs /c/... rewrite.""" + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert ( + _bash_safe_path("C:/Users/Alexander/.hermes/cache/terminal/hermes-snap-x.sh") + == "/c/Users/Alexander/.hermes/cache/terminal/hermes-snap-x.sh" + ) + + def test_mixed_msys_path_normalizes_backslashes(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + mixed = r"/c/Users/Alexander\Documents\NewTEST\readme.txt" + assert _bash_safe_path(mixed) == "/c/Users/Alexander/Documents/NewTEST/readme.txt" + + def test_noop_off_windows(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", False) + path = r"/c/Users\Alexander\Documents" + assert _bash_safe_path(path) == path + + def test_quote_bash_path_quotes_mixed_windows_path(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + quoted = _quote_bash_path( + r"C:\Users\Alexander\AppData\Local\Temp\hermes-snap-abc.sh" + ) + assert "/c/Users/Alexander/AppData/Local/Temp/hermes-snap-abc.sh" in quoted + assert "\\" not in quoted + + # --------------------------------------------------------------------------- # _resolve_safe_cwd — Windows fast path # --------------------------------------------------------------------------- @@ -278,6 +328,88 @@ class TestWindowsMsysPathconvDefaults: assert run_env.get("MSYS2_ARG_CONV_EXCL") == "/custom" +# --------------------------------------------------------------------------- +# Git Bash coreutils on PATH — non-login ``bash -c`` fallback (empty +# write_file error / terminal exit 127 when login bash is broken) +# --------------------------------------------------------------------------- + +class TestGitBashCoreutilsOnPath: + def _fake_isdir(self, existing): + existing = {e.replace("\\", "/") for e in existing} + return lambda p: p.replace("\\", "/") in existing + + def test_derives_dirs_from_portablegit_layout(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + monkeypatch.setattr(local_mod, "_find_bash", lambda: "/pg/bin/bash.exe") + existing = {"/pg/mingw64/bin", "/pg/usr/bin", "/pg/bin"} + monkeypatch.setattr(local_mod.os.path, "isdir", self._fake_isdir(existing)) + + dirs = _git_bash_bin_dirs() + + # usr/bin is the load-bearing coreutils dir; mingw64 precedes it. + assert "/pg/usr/bin" in dirs + assert dirs.index("/pg/mingw64/bin") < dirs.index("/pg/usr/bin") + # Non-existent dirs (mingw32, usr/local/bin) are excluded. + assert "/pg/mingw32/bin" not in dirs + + def test_derives_dirs_from_mingit_usr_bin_layout(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + monkeypatch.setattr(local_mod, "_find_bash", lambda: "/mg/usr/bin/bash.exe") + existing = {"/mg/usr/bin", "/mg/mingw64/bin"} + monkeypatch.setattr(local_mod.os.path, "isdir", self._fake_isdir(existing)) + + dirs = _git_bash_bin_dirs() + + # MinGit ships bash under usr\bin; root must still resolve to /mg. + assert "/mg/usr/bin" in dirs + assert "/mg/mingw64/bin" in dirs + + def test_empty_off_windows(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", False) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + assert _git_bash_bin_dirs() == [] + + def test_empty_when_bash_unresolvable(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + + def boom(): + raise RuntimeError("Git Bash not found") + + monkeypatch.setattr(local_mod, "_find_bash", boom) + assert _git_bash_bin_dirs() == [] + + def test_prepend_is_idempotent(self, monkeypatch): + # Simulate Windows' ``;`` separator so drive-letter colons in fake + # paths don't collide with the POSIX ``:`` pathsep on the test host. + monkeypatch.setattr(os, "pathsep", ";") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", ["/pg/usr/bin", "/pg/bin"]) + already = r"/pg/usr/bin;C:\Windows\System32;/pg/bin" + assert _prepend_git_bash_dirs(already) == already + + def test_make_run_env_prepends_coreutils_on_windows(self, monkeypatch): + monkeypatch.setattr(os, "pathsep", ";") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", ["/pg/mingw64/bin", "/pg/usr/bin"]) + run_env = _make_run_env({"PATH": r"C:\Windows\System32"}) + path = run_env.get("PATH") or run_env.get("Path") + entries = path.split(";") + # Coreutils dirs land before System32 so bash resolves cat/find/sort + # to the GNU tools, not the same-named Windows executables. + assert "/pg/usr/bin" in entries + assert entries.index("/pg/usr/bin") < entries.index(r"C:\Windows\System32") + + def test_make_run_env_noop_on_posix(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", False) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + run_env = _make_run_env({"PATH": "/usr/bin:/bin"}) + # No Windows git dirs injected on POSIX. + assert "mingw64" not in run_env["PATH"] + + # --------------------------------------------------------------------------- # Command wrapping — native Windows cwd must be Git Bash-friendly for cd # --------------------------------------------------------------------------- @@ -306,7 +438,7 @@ class TestWrapCommandWindowsNativeCwd: captured = {} def fake_run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None): - captured["script"] = cmd_string + captured.setdefault("script", cmd_string) # bootstrap only; ignore the failure-path probe raise RuntimeError("stop after capturing bootstrap") monkeypatch.setattr(LocalEnvironment, "_run_bash", fake_run_bash) @@ -317,3 +449,61 @@ class TestWrapCommandWindowsNativeCwd: assert "builtin cd -- /c/Users/liush 2>/dev/null || true" in captured["script"] assert r"C:\Users\liush" not in captured["script"] + + def test_init_session_bootstrap_quotes_snapshot_paths_in_msys_form(self, monkeypatch): + """Snapshot paths must reach bash as /c/... — C:/... still trips MSYS + arg conversion during bash -l and surfaces as \\drivers\\etc.""" + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + + captured = {} + + def fake_run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None): + captured.setdefault("script", cmd_string) # bootstrap only; ignore the failure-path probe + raise RuntimeError("stop after capturing bootstrap") + + monkeypatch.setattr(LocalEnvironment, "_run_bash", fake_run_bash) + + # Production shape: get_temp_dir forces forward slashes but keeps C:. + snap = "C:/Users/Alexander/.hermes/cache/terminal/hermes-snap-deadbeef.sh" + with patch.object(LocalEnvironment, "__init__", lambda self, **kw: None): + env = LocalEnvironment.__new__(LocalEnvironment) + BaseEnvironment.__init__( + env, + cwd=r"C:\Users\Alexander\Documents", + timeout=10, + ) + env._snapshot_path = snap + env._cwd_file = snap + ".cwd" + env.init_session() + + script = captured["script"] + assert "/c/Users/Alexander/.hermes/cache/terminal/hermes-snap-deadbeef.sh" in script + assert "C:/Users/Alexander" not in script + assert r"C:\Users\Alexander" not in script + + def test_init_session_bootstrap_rewrites_backslash_snapshot_paths(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + + captured = {} + + def fake_run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None): + captured.setdefault("script", cmd_string) # bootstrap only; ignore the failure-path probe + raise RuntimeError("stop after capturing bootstrap") + + monkeypatch.setattr(LocalEnvironment, "_run_bash", fake_run_bash) + + snap = r"C:\Users\Alexander\AppData\Local\Temp\hermes-snap-deadbeef.sh" + with patch.object(LocalEnvironment, "__init__", lambda self, **kw: None): + env = LocalEnvironment.__new__(LocalEnvironment) + BaseEnvironment.__init__( + env, + cwd=r"C:\Users\Alexander\Documents", + timeout=10, + ) + env._snapshot_path = snap + env._cwd_file = snap + ".cwd" + env.init_session() + + script = captured["script"] + assert "/c/Users/Alexander/AppData/Local/Temp/hermes-snap-deadbeef.sh" in script + assert r"C:\Users\Alexander\AppData" not in script diff --git a/tests/tools/test_mcp_resource_content.py b/tests/tools/test_mcp_resource_content.py new file mode 100644 index 000000000000..3111c2933020 --- /dev/null +++ b/tests/tools/test_mcp_resource_content.py @@ -0,0 +1,281 @@ +"""Tests for MCP ResourceLink / EmbeddedResource / AudioContent handling. + +Regression coverage for a customer report (2026-07): non-image binary +resources returned through MCP resource blocks were silently dropped from +tool results, so a PDF-returning MCP tool appeared to return metadata only. +""" + +import base64 +import json +from types import SimpleNamespace + +import pytest + + +PDF_BYTES = b"%PDF-1.4 fake pdf payload for tests" + + +def _blob_resource(data: bytes, uri="slack://files/F123/report.pdf", mime="application/pdf"): + return SimpleNamespace( + uri=uri, + mimeType=mime, + blob=base64.b64encode(data).decode("ascii"), + text=None, + ) + + +def _embedded(resource): + return SimpleNamespace(type="resource", resource=resource) + + +@pytest.fixture() +def doc_cache(tmp_path, monkeypatch): + """Point the document cache at a temp dir.""" + import gateway.platforms.base as base + + monkeypatch.setattr(base, "DOCUMENT_CACHE_DIR", tmp_path) + monkeypatch.setenv("HERMES_DOCUMENT_CACHE_DIR", str(tmp_path)) + # _resolve_cache_dir consults the module constant; patching the constant + # is sufficient because the import-default comparison detects the change. + return tmp_path + + +class TestRenderResourceBlock: + def test_embedded_pdf_blob_is_materialized(self, doc_cache): + from tools.mcp_tool import _render_mcp_resource_block + + out = _render_mcp_resource_block(_embedded(_blob_resource(PDF_BYTES)), "slack") + assert "saved to" in out + assert "application/pdf" in out + # Extract path and verify bytes round-trip + path = out.split("saved to ", 1)[1].split(" (", 1)[0] + with open(path, "rb") as fh: + assert fh.read() == PDF_BYTES + assert "report.pdf" in path + + def test_embedded_text_resource_is_inlined(self): + from tools.mcp_tool import _render_mcp_resource_block + + res = SimpleNamespace(uri="mem://notes", mimeType="text/plain", text="hello world", blob=None) + assert _render_mcp_resource_block(_embedded(res), "srv") == "hello world" + + def test_resource_link_preserves_uri_and_points_at_reader(self): + from tools.mcp_tool import _render_mcp_resource_block + + link = SimpleNamespace( + type="resource_link", + uri="slack://files/F123", + name="report.pdf", + mimeType="application/pdf", + ) + out = _render_mcp_resource_block(link, "slack") + assert "slack://files/F123" in out + # Must be the real wire name (mcp____read_resource), not a + # made-up "_read_resource" the agent can't actually call. + assert "mcp__slack__read_resource" in out + assert "report.pdf" in out + + def test_oversized_blob_fails_explicitly_without_writing(self, doc_cache, monkeypatch): + import tools.mcp_tool as m + + monkeypatch.setattr(m, "_MCP_RESOURCE_MAX_BYTES", 8) + out = m._render_mcp_resource_block(_embedded(_blob_resource(PDF_BYTES)), "srv") + assert "too large" in out + assert not list(doc_cache.glob("doc_*")) + + def test_malformed_base64_fails_explicitly(self): + from tools.mcp_tool import _render_mcp_resource_block + + res = SimpleNamespace(uri="x://y", mimeType="application/pdf", blob="!!!not-base64!!!", text=None) + out = _render_mcp_resource_block(_embedded(res), "srv") + assert "could not be decoded" in out + + def test_non_resource_block_returns_empty(self): + from tools.mcp_tool import _render_mcp_resource_block + + assert _render_mcp_resource_block(SimpleNamespace(type="text", text="hi"), "srv") == "" + + def test_path_traversal_uri_is_neutralized(self, doc_cache): + from tools.mcp_tool import _render_mcp_resource_block + + res = _blob_resource(PDF_BYTES, uri="evil://host/../../etc/passwd") + out = _render_mcp_resource_block(_embedded(res), "srv") + assert "saved to" in out + path = out.split("saved to ", 1)[1].split(" (", 1)[0] + assert str(doc_cache) in path + assert "/etc/passwd" not in path + + +class TestResourceFilename: + def test_uri_last_segment_used(self): + from tools.mcp_tool import _mcp_resource_filename + + assert _mcp_resource_filename("slack://f/ABC/quarterly.pdf", "application/pdf") == "quarterly.pdf" + + def test_fallback_to_mime_extension(self): + from tools.mcp_tool import _mcp_resource_filename + + name = _mcp_resource_filename("", "application/pdf") + assert name.endswith(".pdf") + + def test_dotdot_rejected(self): + from tools.mcp_tool import _mcp_resource_filename + + assert _mcp_resource_filename("x://y/..", "application/pdf") != ".." + + def test_control_chars_stripped(self): + from tools.mcp_tool import _mcp_resource_filename + + name = _mcp_resource_filename("x://h/report.pdf%0Ainjected%1b[31m", "application/pdf") + assert "\n" not in name and "\x1b" not in name + + def test_long_filename_capped_preserving_extension(self): + from tools.mcp_tool import _mcp_resource_filename + + name = _mcp_resource_filename("x://h/" + "a" * 500 + ".pdf", "application/pdf") + assert len(name) <= 150 + assert name.endswith(".pdf") + + +class TestPreDecodeSizeCap: + def test_oversized_b64_rejected_before_decode(self, monkeypatch): + import tools.mcp_tool as m + + monkeypatch.setattr(m, "_MCP_RESOURCE_MAX_B64_CHARS", 16) + res = SimpleNamespace( + uri="x://y/big.pdf", mimeType="application/pdf", + blob="A" * 100, text=None, + ) + called = [] + monkeypatch.setattr(base64, "b64decode", lambda *a, **k: called.append(1)) + out = m._render_mcp_resource_block(_embedded(res), "srv") + assert "too large" in out + assert not called + + +class TestAudioBlock: + def test_non_audio_returns_empty(self): + from tools.mcp_tool import _cache_mcp_audio_block + + block = SimpleNamespace(data=base64.b64encode(b"x").decode(), mimeType="application/pdf") + assert _cache_mcp_audio_block(block) == "" + + def test_audio_block_cached_as_media(self, tmp_path, monkeypatch): + import gateway.platforms.base as base + from tools.mcp_tool import _cache_mcp_audio_block + + monkeypatch.setattr(base, "AUDIO_CACHE_DIR", tmp_path) + block = SimpleNamespace( + data=base64.b64encode(b"RIFFfakewav").decode(), + mimeType="audio/wav", + ) + out = _cache_mcp_audio_block(block) + assert out.startswith("MEDIA:") + + +class TestToolResultLoopOrdering: + def test_mixed_blocks_preserve_order(self, doc_cache): + """Simulate the tool-result block loop with text + pdf resource.""" + from tools.mcp_tool import ( + _cache_mcp_image_block, + _cache_mcp_audio_block, + _render_mcp_resource_block, + ) + + blocks = [ + SimpleNamespace(type="text", text="File ID: F123\nMIME Type: application/pdf"), + _embedded(_blob_resource(PDF_BYTES)), + ] + parts = [] + for block in blocks: + if getattr(block, "text", None): + parts.append(block.text) + continue + tag = _cache_mcp_image_block(block) or _cache_mcp_audio_block(block) + if tag: + parts.append(tag) + continue + rendered = _render_mcp_resource_block(block, "slack") + if rendered: + parts.append(rendered) + assert len(parts) == 2 + assert parts[0].startswith("File ID") + assert "saved to" in parts[1] + + def test_existing_image_behavior_unchanged(self): + from tools.mcp_tool import _cache_mcp_image_block + + block = SimpleNamespace( + data=base64.b64encode(b"some bytes").decode("ascii"), + mimeType="application/pdf", + ) + assert _cache_mcp_image_block(block) == "" + + +class TestErrorPathResourceText: + """isError payloads must surface EmbeddedResource text, not drop it.""" + + @pytest.fixture() + def _handler(self, monkeypatch): + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch as mock_patch + + from tools import mcp_tool + + fake_session = MagicMock() + fake_server = SimpleNamespace(session=fake_session, _rpc_lock=None) + + def _fake_run_on_mcp_loop(coro_or_factory, timeout=30): + coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory + loop = asyncio.new_event_loop() + try: + async def _install_lock_and_run(): + for srv in list(mcp_tool._servers.values()): + if getattr(srv, "_rpc_lock", None) is None: + srv._rpc_lock = asyncio.Lock() + return await coro + + return loop.run_until_complete(_install_lock_and_run()) + finally: + loop.close() + + with mock_patch.dict(mcp_tool._servers, {"test-server": fake_server}), \ + mock_patch("tools.mcp_tool._run_on_mcp_loop", side_effect=_fake_run_on_mcp_loop): + fake_session.call_tool = AsyncMock() + yield fake_session, mcp_tool._make_tool_handler("test-server", "my-tool", 30.0) + + def test_error_embedded_resource_text_surfaced(self, _handler): + from unittest.mock import AsyncMock + + session, handler = _handler + res = SimpleNamespace(uri="mem://err", mimeType="text/plain", + text="quota exceeded for workspace W1", blob=None) + session.call_tool = AsyncMock(return_value=SimpleNamespace( + content=[_embedded(res)], isError=True, structuredContent=None, + )) + data = json.loads(handler({})) + assert "quota exceeded for workspace W1" in data["error"] + + def test_error_mixed_text_and_resource(self, _handler): + from unittest.mock import AsyncMock + + session, handler = _handler + res = SimpleNamespace(uri="mem://err", mimeType="text/plain", + text=" — details in resource", blob=None) + session.call_tool = AsyncMock(return_value=SimpleNamespace( + content=[SimpleNamespace(type="text", text="tool failed"), _embedded(res)], + isError=True, structuredContent=None, + )) + data = json.loads(handler({})) + assert "tool failed" in data["error"] + assert "details in resource" in data["error"] + + def test_error_with_no_text_blocks_falls_back(self, _handler): + from unittest.mock import AsyncMock + + session, handler = _handler + session.call_tool = AsyncMock(return_value=SimpleNamespace( + content=[], isError=True, structuredContent=None, + )) + data = json.loads(handler({})) + assert data["error"] == "MCP tool returned an error" diff --git a/tests/tools/test_media_caption_split.py b/tests/tools/test_media_caption_split.py new file mode 100644 index 000000000000..a4a1c7953177 --- /dev/null +++ b/tests/tools/test_media_caption_split.py @@ -0,0 +1,115 @@ +"""Guard test for the MEDIA: caption chokepoint (_media_caption_split). + +`hermes send` strips the MEDIA: tag and leaves the remaining prose as the +accompanying text. Historically every standalone sender posted that text as a +*separate* message before an uncaptioned media bubble, splitting +``hermes send --to whatsapp "MEDIA:/x.png This Caption"`` into two parts. + +`_media_caption_split` is the single enforced decision point that all standalone +senders (WhatsApp, Telegram, Discord) consult to decide whether the text should +ride on the media bubble as a native caption. This test pins that contract so +the platforms can't diverge. +""" + +from tools.send_message_tool import ( + _DEFAULT_CAPTION_LIMIT, + _TELEGRAM_CAPTION_LIMIT, + _media_caption_split, +) + + +def test_single_image_short_text_becomes_caption(): + caption, body = _media_caption_split( + "This Caption", [("/tmp/F22.png", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption == "This Caption" + assert body == "" + + +def test_single_video_short_text_becomes_caption(): + caption, body = _media_caption_split( + "Model unit tour", [("/tmp/tour.mp4", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption == "Model unit tour" + assert body == "" + + +def test_single_document_short_text_becomes_caption(): + caption, body = _media_caption_split( + "Q3 report", [("/tmp/report.pdf", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption == "Q3 report" + assert body == "" + + +def test_multi_file_keeps_separate_body(): + text = "two photos" + caption, body = _media_caption_split( + text, + [("/tmp/a.png", False), ("/tmp/b.png", False)], + max_caption_len=_DEFAULT_CAPTION_LIMIT, + ) + assert caption is None + assert body == text + + +def test_voice_note_keeps_separate_body(): + text = "listen to this" + caption, body = _media_caption_split( + text, [("/tmp/note.ogg", True)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption is None + assert body == text + + +def test_empty_text_no_caption(): + caption, body = _media_caption_split( + " ", [("/tmp/a.png", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption is None + # body is returned unchanged (still whitespace) — sender's own guards drop it + assert body == " " + + +def test_no_media_no_caption(): + caption, body = _media_caption_split( + "hello", [], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption is None + assert body == "hello" + + +def test_text_over_limit_stays_separate_body(): + long_text = "x" * (_TELEGRAM_CAPTION_LIMIT + 1) + caption, body = _media_caption_split( + long_text, [("/tmp/a.png", False)], max_caption_len=_TELEGRAM_CAPTION_LIMIT + ) + assert caption is None + assert body == long_text + + +def test_text_at_limit_still_captions(): + text = "y" * _TELEGRAM_CAPTION_LIMIT + caption, body = _media_caption_split( + text, [("/tmp/a.png", False)], max_caption_len=_TELEGRAM_CAPTION_LIMIT + ) + assert caption == text + assert body == "" + + +def test_caption_is_stripped(): + caption, body = _media_caption_split( + " padded caption ", [("/tmp/a.png", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption == "padded caption" + assert body == "" + + +def test_unknown_extension_keeps_separate_body(): + # A non-captionable kind (e.g. an audio note that isn't flagged voice) + text = "some audio" + caption, body = _media_caption_split( + text, [("/tmp/song.mp3", False)], max_caption_len=_DEFAULT_CAPTION_LIMIT + ) + assert caption is None + assert body == text diff --git a/tests/tools/test_modal_sandbox_fixes.py b/tests/tools/test_modal_sandbox_fixes.py index 3bccb3e07d13..dddfe134edb6 100644 --- a/tests/tools/test_modal_sandbox_fixes.py +++ b/tests/tools/test_modal_sandbox_fixes.py @@ -350,6 +350,7 @@ class TestDockerHostBindApproval: def test_isolated_docker_keeps_fast_path(self, monkeypatch): """Isolated Docker still bypasses dangerous-command approval.""" import tools.approval as A + self._isolate_approval_state(monkeypatch) monkeypatch.setenv("HERMES_EXEC_ASK", "1") monkeypatch.setattr( "tools.tirith_security.check_command_security", @@ -358,9 +359,26 @@ class TestDockerHostBindApproval: has_host_access=False) assert res["approved"] is True + @staticmethod + def _isolate_approval_state(monkeypatch): + """Clear approval state that leaks in from the real user config. + + ``tools.approval`` loads ``command_allowlist`` into module-level + ``_permanent_approved`` at import time. This file imports + ``tools.terminal_tool`` at module level (collection time — BEFORE the + hermetic HERMES_HOME fixture runs), so on a dev machine whose real + config permanently allowlists e.g. "delete in root path" the guard + under test silently approves and the assertions flip. CI never has + such an allowlist, making this a local-only flake. + """ + import tools.approval as A + monkeypatch.setattr(A, "_permanent_approved", set()) + monkeypatch.setattr(A, "_session_approved", {}) + def test_host_bound_docker_requires_approval(self, monkeypatch): """Host-bound Docker dangerous command escalates instead of bypassing.""" import tools.approval as A + self._isolate_approval_state(monkeypatch) monkeypatch.setenv("HERMES_EXEC_ASK", "1") monkeypatch.setattr( "tools.tirith_security.check_command_security", @@ -374,6 +392,7 @@ class TestDockerHostBindApproval: def test_execute_code_isolated_docker_keeps_fast_path(self, monkeypatch): """Isolated Docker execute_code still bypasses the guard.""" import tools.approval as A + self._isolate_approval_state(monkeypatch) monkeypatch.setenv("HERMES_EXEC_ASK", "1") res = A.check_execute_code_guard("import os", "docker", has_host_access=False) @@ -382,6 +401,7 @@ class TestDockerHostBindApproval: def test_execute_code_host_bound_docker_requires_approval(self, monkeypatch): """Host-bound Docker execute_code does not get the container fast-path.""" import tools.approval as A + self._isolate_approval_state(monkeypatch) monkeypatch.setenv("HERMES_EXEC_ASK", "1") res = A.check_execute_code_guard( "import os; os.system('rm -rf /workspace')", "docker", diff --git a/tests/tools/test_patch_parser.py b/tests/tools/test_patch_parser.py index 79077a84a165..52cca09fa371 100644 --- a/tests/tools/test_patch_parser.py +++ b/tests/tools/test_patch_parser.py @@ -401,6 +401,74 @@ class TestValidationPhase: assert result.success is True assert set(written.keys()) == {"a.py", "b.py"} + def test_context_only_hunk_does_not_reject_later_real_hunk(self): + patch = """\ +*** Begin Patch +*** Update File: a.py +@@ anchor @@ + anchor +@@ value @@ +-value = 1 ++value = 2 +*** End Patch""" + ops, err = parse_v4a_patch(patch) + assert err is None + + class FakeFileOps: + written = None + def read_file_raw(self, path): + return SimpleNamespace(content="anchor\nvalue = 1\n", error=None) + def write_file(self, path, content): + self.written = content + return SimpleNamespace(error=None) + + file_ops = FakeFileOps() + result = apply_v4a_operations(ops, file_ops) + assert result.success is True + assert file_ops.written == "anchor\nvalue = 2\n" + + def test_patch_with_only_context_hunks_reports_no_changes(self): + patch = """\ +*** Begin Patch +*** Update File: a.py +@@ anchor @@ + anchor +*** End Patch""" + ops, err = parse_v4a_patch(patch) + assert err is None + + class FakeFileOps: + def read_file_raw(self, path): + return SimpleNamespace(content="anchor\n", error=None) + def write_file(self, path, content): + raise AssertionError("no-op patch must not write") + + result = apply_v4a_operations(ops, FakeFileOps()) + assert result.success is False + assert "no changes" in result.error.lower() + + def test_validation_error_identifies_hunk_number(self): + patch = """\ +*** Begin Patch +*** Update File: a.py +@@ first @@ +-first = 1 ++first = 2 +@@ missing @@ +-does_not_exist = 1 ++does_not_exist = 2 +*** End Patch""" + ops, err = parse_v4a_patch(patch) + assert err is None + + class FakeFileOps: + def read_file_raw(self, path): + return SimpleNamespace(content="first = 1\n", error=None) + + result = apply_v4a_operations(ops, FakeFileOps()) + assert result.success is False + assert "hunk 2" in result.error.lower() + class TestApplyDelete: """Tests for _apply_delete producing a real unified diff.""" diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index 4d8b56556d7b..49d1193cf676 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -47,6 +47,70 @@ class TestRegisterAndDispatch: result = json.loads(reg.dispatch("echo", {"msg": "hi"})) assert result == {"msg": "hi"} + def test_dispatch_preserves_supported_multimodal_result(self): + reg = ToolRegistry() + multimodal = { + "_multimodal": True, + "content": [{"type": "text", "text": "captured"}], + "text_summary": "captured", + } + reg.register( + name="capture", + toolset="computer_use", + schema=_make_schema("capture"), + handler=lambda args, **kw: multimodal, + ) + + assert reg.dispatch("capture", {}) is multimodal + + def test_dispatch_rejects_unsupported_handler_results_with_structured_error(self): + invalid_results = ({"ok": True}, b"bytes", None, 42) + + for invalid in invalid_results: + reg = ToolRegistry() + reg.register( + name="bad_result", + toolset="core", + schema=_make_schema("bad_result"), + handler=lambda args, _invalid=invalid, **kw: _invalid, + ) + + raw = reg.dispatch("bad_result", {}) + result = json.loads(raw) + + assert isinstance(raw, str) + assert result["error_type"] == "tool_result_contract" + assert result["tool"] == "bad_result" + assert result["result_type"] == type(invalid).__name__ + assert "unsupported result type" in result["error"] + + def test_handler_contract_error_survives_model_tools_pipeline(self): + from model_tools import handle_function_call, registry + + name = "test_invalid_registry_result" + registry.register( + name=name, + toolset="core", + schema=_make_schema(name), + handler=lambda args, **kw: None, + ) + try: + raw = handle_function_call( + name, + {}, + task_id="contract-test", + skip_pre_tool_call_hook=True, + ) + finally: + registry.deregister(name) + + result = json.loads(raw) + assert len(raw) > 0 # downstream sizing/logging remains safe + assert json.loads(json.dumps({"content": raw}))["content"] == raw + assert result["error_type"] == "tool_result_contract" + assert result["tool"] == name + assert result["result_type"] == "NoneType" + class TestGetDefinitions: def test_returns_openai_format(self): diff --git a/tests/tools/test_restored_delegation_ownership.py b/tests/tools/test_restored_delegation_ownership.py new file mode 100644 index 000000000000..1002decf3cd8 --- /dev/null +++ b/tests/tools/test_restored_delegation_ownership.py @@ -0,0 +1,149 @@ +"""Regression coverage for #64484 — durable-restored delegation completions +must never be adopted by a session that cannot positively prove ownership. + +Layers under test: +1. ``restore_undelivered_completions`` stamps every restored event with + ``restored=True`` (in-memory only). +2. ``ProcessRegistry.drain_notifications`` with NO filter (legacy + consume-everything CLI path) re-queues restored events instead of + consuming them. +3. Same-process (non-restored) keyless events keep the legacy behavior. +4. An owner with a matching session_key still receives its restored event. +""" + +import json +import queue + +from tools.process_registry import ProcessRegistry + + +def _make_registry(): + reg = ProcessRegistry.__new__(ProcessRegistry) + import threading + + reg._running = {} + reg._finished = {} + reg._lock = threading.Lock() + reg.completion_queue = queue.Queue() + reg._completion_consumed = set() + reg._poll_observed = set() + return reg + + +def _delegation_event(session_key="", restored=False, delegation_id="d1"): + evt = { + "type": "async_delegation", + "delegation_id": delegation_id, + "session_key": session_key, + "origin_ui_session_id": "", + "goal": "secret goal", + "status": "success", + "summary": "SECRET RESULT", + "api_calls": 3, + "duration_seconds": 1.5, + "dispatched_at": 1.0, + "completed_at": 2.0, + } + if restored: + evt["restored"] = True + return evt + + +def test_restore_stamps_restored_flag(tmp_path, monkeypatch): + """Every durable completion re-enqueued at startup carries restored=True.""" + import tools.async_delegation as ad + + monkeypatch.setattr(ad, "_db_path", lambda: tmp_path / "async_delegations.db") + record = { + "delegation_id": "d-old", + "goal": "old goal", + "context": None, + "toolsets": None, + "role": "leaf", + "model": "m", + "session_key": "OLD_SESSION_A", + "origin_ui_session_id": "", + "parent_session_id": "OLD_SESSION_A", + "status": "running", + "dispatched_at": 1.0, + "completed_at": None, + "interrupt_fn": None, + } + ad._persist_dispatch(record) + evt = _delegation_event(session_key="OLD_SESSION_A", delegation_id="d-old") + ad._persist_completion(evt, {"summary": "SECRET RESULT"}) + + q = queue.Queue() + restored = ad.restore_undelivered_completions(q) + assert restored == 1 + got = q.get_nowait() + assert got["restored"] is True + assert got["session_key"] == "OLD_SESSION_A" + + # The stamp is in-memory only — the durable payload is unchanged. + with ad._connect() as conn: + row = conn.execute( + "SELECT event_json FROM async_delegations WHERE delegation_id='d-old'" + ).fetchone() + assert "restored" not in json.loads(row[0]) + + +def test_unfiltered_drain_never_consumes_restored_events(): + """The legacy consume-everything branch must fail closed on restored events.""" + reg = _make_registry() + reg.completion_queue.put(_delegation_event(session_key="DEAD_SESSION", restored=True)) + + results = reg.drain_notifications() # no filter — legacy CLI post-turn shape + + assert results == [] + # Still queued for its real owner. + assert reg.completion_queue.qsize() == 1 + assert reg.completion_queue.get_nowait()["session_key"] == "DEAD_SESSION" + + +def test_unfiltered_drain_keeps_legacy_behavior_for_same_process_events(): + """Non-restored keyless events (created by this process) are still consumed.""" + reg = _make_registry() + reg.completion_queue.put(_delegation_event(session_key="")) + + results = reg.drain_notifications() + + assert len(results) == 1 + assert results[0][0]["delegation_id"] == "d1" + assert reg.completion_queue.empty() + + +def test_owner_session_key_drain_consumes_restored_event(): + """The owning session (key match) still receives its restored completion.""" + reg = _make_registry() + reg.completion_queue.put(_delegation_event(session_key="OWNER", restored=True)) + + results = reg.drain_notifications(session_key="OWNER") + + assert len(results) == 1 + assert results[0][0]["session_key"] == "OWNER" + assert reg.completion_queue.empty() + + +def test_foreign_session_key_drain_requeues_restored_event(): + """A different session's keyed drain must not claim the restored event.""" + reg = _make_registry() + reg.completion_queue.put(_delegation_event(session_key="OWNER", restored=True)) + + results = reg.drain_notifications(session_key="SOMEONE_ELSE") + + assert results == [] + assert reg.completion_queue.qsize() == 1 + + +def test_owns_event_callback_beats_restored_flag(): + """A positive-proof ownership callback consumes restored events it owns.""" + reg = _make_registry() + reg.completion_queue.put(_delegation_event(session_key="OWNER", restored=True)) + + results = reg.drain_notifications( + owns_event=lambda e: e.get("session_key") == "OWNER" + ) + + assert len(results) == 1 + assert reg.completion_queue.empty() diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index ed0423322632..55c5165901b6 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -146,11 +146,14 @@ class _patch_discord_sender: self._entry = None self._original = None - async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, media_files=None): + async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, media_files=None, caption=None): token = getattr(pconfig, "token", None) + # Only forward caption= when set, so mocks written against the + # pre-caption signature (no caption kwarg) keep working. + extra = {"caption": caption} if caption is not None else {} return await self._mock( token, chat_id, message, - thread_id=thread_id, media_files=media_files, + thread_id=thread_id, media_files=media_files, **extra, ) def __enter__(self): @@ -595,7 +598,9 @@ class TestSendMessageTool: class TestSendTelegramMediaDelivery: - def test_sends_text_then_photo_for_media_tag(self, tmp_path, monkeypatch): + def test_sends_photo_with_caption_for_media_tag(self, tmp_path, monkeypatch): + # A single captionable image + short text now rides as the photo's + # native caption (MEDIA: caption), not a separate text message. image_path = tmp_path / "photo.png" image_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32) @@ -619,11 +624,10 @@ class TestSendTelegramMediaDelivery: assert result["success"] is True assert result["message_id"] == "2" - bot.send_message.assert_awaited_once() + # No separate text send — the caption rides the photo bubble. + bot.send_message.assert_not_awaited() bot.send_photo.assert_awaited_once() - sent_text = bot.send_message.await_args.kwargs["text"] - assert "MEDIA:" not in sent_text - assert sent_text == "Hello there" + assert bot.send_photo.await_args.kwargs.get("caption") == "Hello there" def test_sends_voice_for_ogg_with_voice_directive(self, tmp_path, monkeypatch): voice_path = tmp_path / "voice.ogg" @@ -2048,7 +2052,7 @@ class TestSendToPlatformDiscordMedia: assert call_log[1]["media_files"] == [("/fake/img.png", False)] # Last chunk: media attached def test_single_chunk_gets_media(self): - """Short message (single chunk) gets media_files directly.""" + """Short message + single image rides as the media caption.""" send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) with _patch_discord_sender(send_mock): @@ -2066,6 +2070,9 @@ class TestSendToPlatformDiscordMedia: send_mock.assert_awaited_once() call_kwargs = send_mock.await_args.kwargs assert call_kwargs["media_files"] == [("/fake/img.png", False)] + # Text rides as the caption, not a separate positional message body. + assert call_kwargs.get("caption") == "short message" + assert send_mock.await_args.args[2] == "" class TestSendMatrixUrlEncoding: @@ -3334,13 +3341,17 @@ class TestSendTelegramThreadNotFoundRetry: "retry should drop message_thread_id after thread-not-found" def test_disable_web_page_preview_not_leaked_to_media_sends(self): - """disable_web_page_preview should only appear in text send, not media sends.""" - text_kwargs_seen = [] + """disable_web_page_preview must never leak into a media send. + + A single captionable file + short text now rides as the document's + caption (no separate text send), so the invariant to protect is that + the captioned send_document does not inherit disable_web_page_preview + (valid only for send_message). + """ media_kwargs_seen = [] class FakeBot: async def send_message(self, **kwargs): - text_kwargs_seen.append(kwargs) return SimpleNamespace(message_id=1) async def send_document(self, **kwargs): @@ -3364,9 +3375,9 @@ class TestSendTelegramThreadNotFoundRetry: result = asyncio.run(run_test()) assert result["success"] is True - # Text send should have disable_web_page_preview - assert text_kwargs_seen[0].get("disable_web_page_preview") is True - # Media send should NOT have disable_web_page_preview + # Caption rides the document bubble. + assert media_kwargs_seen[0].get("caption") == "check preview" + # Media send must NOT carry disable_web_page_preview. assert "disable_web_page_preview" not in media_kwargs_seen[0], \ "disable_web_page_preview leaked into send_document kwargs" finally: diff --git a/tests/tools/test_skill_bundle_provenance.py b/tests/tools/test_skill_bundle_provenance.py new file mode 100644 index 000000000000..33878b3f9caf --- /dev/null +++ b/tests/tools/test_skill_bundle_provenance.py @@ -0,0 +1,260 @@ +"""Multi-file third-party skill bundles and scanner provenance (#60598).""" + +import json +import subprocess +import threading +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from io import StringIO +from pathlib import Path + +import pytest +from rich.console import Console + +from tools.skills_guard import SCANNER_VERSION, scan_skill_cached +from tools.skills_hub import GitHubAuth, GitHubSource, HubLockFile, SkillBundle, UrlSource + + +SKILL_MD = """--- +name: demo-bundle +description: A multi-file test skill. +--- +# Demo +Read [the guide](references/guide.md#usage), use `templates/report.md?raw=1`, and run +`scripts/run.py`. See `examples/endpoint-inventory.md`. The repository also +contains assets/logo.png. +""" + + +class _QuietHandler(SimpleHTTPRequestHandler): + def log_message(self, *_args): + pass + + +@pytest.fixture +def served_repo(tmp_path): + repo = tmp_path / "upstream" + repo.mkdir() + (repo / "SKILL.md").write_text(SKILL_MD) + for rel, content in { + "references/guide.md": "safe guide\n", + "templates/report.md": "report\n", + "scripts/run.py": "print('ok')\n", + "assets/logo.png": b"\x89PNG\r\n\x1a\n\x00\xff", + "examples/endpoint-inventory.md": "example\n", + "examples/not-installed.md": "must not be copied\n", + "README.md": "must not be copied\n", + }.items(): + path = repo / rel + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run( + ["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "fixture"], + cwd=repo, + check=True, + ) + + server = ThreadingHTTPServer( + ("127.0.0.1", 0), partial(_QuietHandler, directory=str(repo)) + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield repo, f"http://127.0.0.1:{server.server_port}/SKILL.md" + finally: + server.shutdown() + thread.join() + + +def test_url_source_fetches_only_referenced_allowed_support_directories(served_repo, monkeypatch): + _repo, url = served_repo + monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True) + monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None) + + bundle = UrlSource().fetch(url) + + assert bundle is not None + assert set(bundle.files) == { + "SKILL.md", + "references/guide.md", + "templates/report.md", + "scripts/run.py", + "assets/logo.png", + "examples/endpoint-inventory.md", + } + assert bundle.files["assets/logo.png"] == b"\x89PNG\r\n\x1a\n\x00\xff" + assert "examples/not-installed.md" not in bundle.files + assert bundle.metadata["source_url"] == url + + +def test_url_source_rejects_traversal_reference(monkeypatch): + source = UrlSource() + skill = "---\nname: bad\ndescription: bad\n---\n[bad](references/../../secret.txt)\n" + monkeypatch.setattr(source, "_fetch_text", lambda _url: skill) + + assert source.fetch("https://example.com/bad/SKILL.md") is None + + +def test_github_source_rejects_symlink_in_referenced_directory(monkeypatch): + source = GitHubSource(GitHubAuth()) + monkeypatch.setattr(source, "_fetch_file_content", lambda _repo, path: SKILL_MD if path.endswith("SKILL.md") else "x") + source._tree_cache["owner/repo"] = ( + "main", + [ + {"path": "skill/SKILL.md", "type": "blob", "mode": "100644"}, + {"path": "skill/references/guide.md", "type": "blob", "mode": "120000"}, + ], + ) + + assert source.fetch("owner/repo/skill") is None + + +def test_github_source_fetches_only_exact_references_and_records_tree_revision(monkeypatch): + source = GitHubSource(GitHubAuth()) + skill = "---\nname: demo\ndescription: demo\n---\n[guide](references/guide.md)\n" + fetched = [] + monkeypatch.setattr( + source, + "_fetch_file_content", + lambda _repo, path: skill if path.endswith("SKILL.md") else None, + ) + + def _fetch_bytes(_repo, path): + fetched.append(path) + return b"guide" + + monkeypatch.setattr(source, "_fetch_file_bytes", _fetch_bytes, raising=False) + source._tree_cache["owner/repo"] = ( + "develop", + [ + {"path": "skill/SKILL.md", "type": "blob", "mode": "100644"}, + {"path": "skill/references/guide.md", "type": "blob", "mode": "100644"}, + {"path": "skill/references/unreferenced.md", "type": "blob", "mode": "100644"}, + ], + ) + source._tree_revisions = {"owner/repo": "deadbeef"} + + bundle = source.fetch("owner/repo/skill") + + assert bundle is not None + assert fetched == ["skill/references/guide.md"] + assert bundle.files["references/guide.md"] == b"guide" + assert bundle.metadata["source_url"] == "https://github.com/owner/repo/tree/deadbeef/skill" + assert bundle.metadata["source_revision"] == "deadbeef" + + +def test_scan_cache_records_full_provenance_and_hash_change_forces_rescan(tmp_path): + skill = tmp_path / "skill" + skill.mkdir() + (skill / "SKILL.md").write_text("---\nname: skill\ndescription: test\n---\n# safe\n") + cache = tmp_path / "scan-cache" + + first, first_provenance = scan_skill_cached( + skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache + ) + second, second_provenance = scan_skill_cached( + skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache + ) + (skill / "SKILL.md").write_text("---\nname: skill\ndescription: changed\n---\n# safe\n") + third, third_provenance = scan_skill_cached( + skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache + ) + + assert first.verdict == second.verdict == third.verdict == "safe" + assert first_provenance["fresh"] is True + assert second_provenance["fresh"] is False + assert third_provenance["fresh"] is True + assert first_provenance["bundle_hash"].startswith("sha256:") + assert len(first_provenance["bundle_hash"].split(":", 1)[1]) == 64 + assert third_provenance["bundle_hash"] != first_provenance["bundle_hash"] + assert first_provenance["scanner_version"] == SCANNER_VERSION + assert first_provenance["source_url"] == "https://github.com/owner/repo" + assert isinstance(first_provenance["findings"], list) + assert isinstance(first_provenance["rules"], list) + assert first_provenance["scanned_at"] + + +def test_scan_cache_never_reuses_provenance_across_sources(tmp_path): + skill = tmp_path / "skill" + skill.mkdir() + (skill / "SKILL.md").write_text("---\nname: skill\ndescription: test\n---\n") + cache = tmp_path / "scan-cache" + + _first, first = scan_skill_cached( + skill, source="community", source_url="https://one.example/SKILL.md", cache_dir=cache + ) + _second, second = scan_skill_cached( + skill, source="community", source_url="https://two.example/SKILL.md", cache_dir=cache + ) + + assert first["fresh"] is True + assert second["fresh"] is True + assert second["source_url"] == "https://two.example/SKILL.md" + + +def test_lock_file_persists_scan_provenance(tmp_path): + lock = HubLockFile(tmp_path / "lock.json") + provenance = { + "source_url": "https://example.com/SKILL.md", + "bundle_hash": "sha256:" + "a" * 64, + "scanner_version": SCANNER_VERSION, + "findings": [], + "rules": [], + "scanned_at": "2026-07-09T00:00:00+00:00", + "fresh": True, + } + lock.record_install( + name="demo", source="url", identifier="https://example.com/SKILL.md", + trust_level="community", scan_verdict="safe", skill_hash="sha256:legacy", + install_path="demo", files=["SKILL.md"], scan_provenance=provenance, + ) + + assert lock.get_installed("demo")["scan_provenance"] == provenance + + +def test_real_temp_repo_and_home_install_e2e(served_repo, monkeypatch, tmp_path): + from hermes_cli.skills_hub import do_install + import tools.skills_hub as hub + + _repo, url = served_repo + home = tmp_path / "home" + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True) + monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None) + monkeypatch.setattr(hub, "create_source_router", lambda auth=None: [UrlSource()]) + + sink = StringIO() + do_install(url, console=Console(file=sink, force_terminal=False), skip_confirm=True) + + installed = home / "skills" / "demo-bundle" + assert (installed / "references" / "guide.md").read_text() == "safe guide\n" + assert (installed / "templates" / "report.md").is_file() + assert (installed / "scripts" / "run.py").is_file() + assert (installed / "examples" / "endpoint-inventory.md").is_file() + assert not (installed / "examples" / "not-installed.md").exists() + assert (installed / "assets" / "logo.png").read_bytes() == b"\x89PNG\r\n\x1a\n\x00\xff" + entry = json.loads((home / "skills" / ".hub" / "lock.json").read_text())["installed"]["demo-bundle"] + assert entry["scan_provenance"]["source_url"] == url + assert entry["scan_provenance"]["fresh"] is True + assert "Scan provenance: fresh" in sink.getvalue() + + +def test_bundled_optional_source_still_includes_support_files(tmp_path, monkeypatch): + from tools.skills_hub import OptionalSkillSource + + root = tmp_path / "optional-skills" + skill = root / "category" / "official-demo" + (skill / "references").mkdir(parents=True) + (skill / "SKILL.md").write_text("---\nname: official-demo\ndescription: demo\n---\n") + (skill / "references" / "all.md").write_text("all") + source = OptionalSkillSource() + source._optional_dir = root + + bundle = source.fetch("official/category/official-demo") + assert bundle is not None + assert set(bundle.files) == {"SKILL.md", "references/all.md"} diff --git a/tests/tools/test_skills_tool_discovery_cache.py b/tests/tools/test_skills_tool_discovery_cache.py new file mode 100644 index 000000000000..55908118cfa4 --- /dev/null +++ b/tests/tools/test_skills_tool_discovery_cache.py @@ -0,0 +1,129 @@ +"""Regression tests for the _find_all_skills discovery cache (#58985 salvage). + +Covers the cache-signature fix layered on the cherry-picked contributor +commit: the original keyed the cache on the max mtime of only the TOP-LEVEL +scan dirs, so adding/removing a skill inside a category subdir (which bumps +the category dir's mtime, not the root's) served a stale list indefinitely. +The signature now covers roots + immediate children (mirroring +hermes_cli/profiles.py::_count_skills) plus the disabled-set, with a short +TTL bounding in-place SKILL.md edit staleness. +""" + +import time + +import pytest + +import tools.skills_tool as st + + +@pytest.fixture(autouse=True) +def _fresh_cache(monkeypatch, tmp_path): + """Isolate every test: clear the module cache and point the scan at + an empty external-dirs list + a tmp skills root.""" + st._SKILLS_CACHE.clear() + monkeypatch.setattr(st, "_skills_dir", lambda: tmp_path / "skills") + monkeypatch.setattr( + "agent.skill_utils.get_external_skills_dirs", lambda: [] + ) + monkeypatch.setattr(st, "_get_disabled_skill_names", lambda: set()) + yield + st._SKILLS_CACHE.clear() + + +def _write_skill(root, category, name, description="a skill"): + d = root / "skills" / category / name + d.mkdir(parents=True, exist_ok=True) + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {description}\n---\n# {name}\n", + encoding="utf-8", + ) + return d + + +def test_cache_hit_serves_copies_not_cache_objects(tmp_path): + """Callers mutate the returned dicts (web_server annotates + s['enabled']/s['usage']) — the cache must hand out per-call copies.""" + _write_skill(tmp_path, "cat-a", "skill-one") + first = st._find_all_skills() + assert [s["name"] for s in first] == ["skill-one"] + + # Mutate what the first caller got; the next (cached) call must be clean. + first[0]["enabled"] = False + first.append({"name": "junk"}) + + second = st._find_all_skills() + assert [s["name"] for s in second] == ["skill-one"] + assert "enabled" not in second[0], "cache poisoned by caller mutation" + assert second is not first + + +def test_nested_category_skill_add_invalidates(tmp_path): + """THE bug in the original PR: a new skill inside an existing category + bumps the category dir's mtime only — the root-mtime key missed it.""" + _write_skill(tmp_path, "cat-a", "skill-one") + first = st._find_all_skills() + assert [s["name"] for s in first] == ["skill-one"] + + # Freeze the ROOT dir's mtime so only the category-child signature moves + # (guards against filesystems bumping the parent too). + root = tmp_path / "skills" + root_stat = root.stat() + _write_skill(tmp_path, "cat-a", "skill-two") + import os + os.utime(root, (root_stat.st_atime, root_stat.st_mtime)) + + names = sorted(s["name"] for s in st._find_all_skills()) + assert names == ["skill-one", "skill-two"], ( + "category-nested skill add must invalidate the cache" + ) + + +def test_disabled_set_change_invalidates(tmp_path, monkeypatch): + """Disabling a skill is a config change with NO filesystem mtime bump — + it must still invalidate.""" + _write_skill(tmp_path, "cat-a", "skill-one") + _write_skill(tmp_path, "cat-a", "skill-two") + names = sorted(s["name"] for s in st._find_all_skills()) + assert names == ["skill-one", "skill-two"] + + monkeypatch.setattr(st, "_get_disabled_skill_names", lambda: {"skill-two"}) + names = sorted(s["name"] for s in st._find_all_skills()) + assert names == ["skill-one"], "disabled-set change must invalidate the cache" + + +def test_ttl_expiry_forces_rescan(tmp_path, monkeypatch): + """In-place SKILL.md edits are invisible to any directory signature; + the TTL bounds that staleness.""" + skill_dir = _write_skill(tmp_path, "cat-a", "skill-one", "old description") + first = st._find_all_skills() + assert first[0]["description"] == "old description" + + # Edit the file in place; keep every directory mtime identical. + import os + cat = tmp_path / "skills" / "cat-a" + root = tmp_path / "skills" + stats = {p: p.stat() for p in (root, cat, skill_dir)} + (skill_dir / "SKILL.md").write_text( + "---\nname: skill-one\ndescription: new description\n---\n# skill-one\n", + encoding="utf-8", + ) + for p, s in stats.items(): + os.utime(p, (s.st_atime, s.st_mtime)) + + # Within TTL: stale (documented trade-off). + assert st._find_all_skills()[0]["description"] == "old description" + + # Past TTL: fresh. + monkeypatch.setattr(st, "_SKILLS_CACHE_TTL_SECONDS", 0.0) + assert st._find_all_skills()[0]["description"] == "new description" + + +def test_disabled_and_full_views_cached_separately(tmp_path, monkeypatch): + _write_skill(tmp_path, "cat-a", "skill-one") + _write_skill(tmp_path, "cat-a", "skill-two") + monkeypatch.setattr(st, "_get_disabled_skill_names", lambda: {"skill-two"}) + + filtered = sorted(s["name"] for s in st._find_all_skills()) + everything = sorted(s["name"] for s in st._find_all_skills(skip_disabled=True)) + assert filtered == ["skill-one"] + assert everything == ["skill-one", "skill-two"] diff --git a/tests/tools/test_telegram_send_message_caption.py b/tests/tools/test_telegram_send_message_caption.py new file mode 100644 index 000000000000..aa21331c5f93 --- /dev/null +++ b/tests/tools/test_telegram_send_message_caption.py @@ -0,0 +1,141 @@ +"""Standalone Telegram MEDIA: caption delivery. + +When `hermes send --to telegram "MEDIA:/x.png This Caption"` carries a single +captionable file plus short text, the text must ride on the media bubble as the +sendPhoto/sendVideo/sendDocument ``caption`` rather than being posted as a +separate sendMessage beforehand. Longer text (> Telegram's 1024 caption cap) +falls back to a separate message. The ``telegram`` package is stubbed. +""" + +from __future__ import annotations + +import asyncio +import os +import sys +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def _install_telegram_mock(monkeypatch: pytest.MonkeyPatch, bot_factory: MagicMock) -> None: + parse_mode = SimpleNamespace(MARKDOWN_V2="MarkdownV2", HTML="HTML") + constants_mod = SimpleNamespace(ParseMode=parse_mode) + _MessageEntity = lambda **_kw: SimpleNamespace(**_kw) + telegram_mod = SimpleNamespace( + Bot=bot_factory, + MessageEntity=_MessageEntity, + constants=constants_mod, + ) + monkeypatch.setitem(sys.modules, "telegram", telegram_mod) + monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod) + + +def _make_bot() -> MagicMock: + bot = MagicMock() + bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1)) + bot.send_photo = AsyncMock(return_value=SimpleNamespace(message_id=2)) + bot.send_video = AsyncMock(return_value=SimpleNamespace(message_id=3)) + bot.send_document = AsyncMock(return_value=SimpleNamespace(message_id=4)) + return bot + + +def _no_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + for var in ( + "TELEGRAM_PROXY", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", + "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy", + ): + monkeypatch.delenv(var, raising=False) + monkeypatch.setattr("gateway.run._gateway_runner_ref", lambda: None, raising=False) + monkeypatch.setattr(sys, "platform", "linux") + + +def _tmpfile(suffix: str) -> str: + f = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) + f.write(b"x") + f.close() + return f.name + + +def test_image_caption_rides_bubble_no_separate_text(monkeypatch: pytest.MonkeyPatch) -> None: + from tools.send_message_tool import _send_telegram + + _no_proxy(monkeypatch) + bot = _make_bot() + _install_telegram_mock(monkeypatch, MagicMock(return_value=bot)) + img = _tmpfile(".png") + try: + res = asyncio.run( + _send_telegram("tok", "123", "This Caption", media_files=[(img, False)]) + ) + assert res["success"] is True + # No separate text message; caption rides the photo. + bot.send_message.assert_not_awaited() + bot.send_photo.assert_awaited_once() + assert bot.send_photo.await_args.kwargs.get("caption") == "This Caption" + finally: + os.unlink(img) + + +def test_video_caption_rides_bubble(monkeypatch: pytest.MonkeyPatch) -> None: + from tools.send_message_tool import _send_telegram + + _no_proxy(monkeypatch) + bot = _make_bot() + _install_telegram_mock(monkeypatch, MagicMock(return_value=bot)) + vid = _tmpfile(".mp4") + try: + res = asyncio.run( + _send_telegram("tok", "123", "Model unit tour", media_files=[(vid, False)]) + ) + assert res["success"] is True + bot.send_message.assert_not_awaited() + bot.send_video.assert_awaited_once() + assert bot.send_video.await_args.kwargs.get("caption") == "Model unit tour" + finally: + os.unlink(vid) + + +def test_long_text_falls_back_to_separate_message(monkeypatch: pytest.MonkeyPatch) -> None: + from tools.send_message_tool import _send_telegram + + _no_proxy(monkeypatch) + bot = _make_bot() + _install_telegram_mock(monkeypatch, MagicMock(return_value=bot)) + img = _tmpfile(".png") + long_text = "x" * 1100 # over Telegram's 1024 caption cap + try: + res = asyncio.run( + _send_telegram("tok", "123", long_text, media_files=[(img, False)]) + ) + assert res["success"] is True + # Text too long for a caption — sent as its own message, photo uncaptioned. + bot.send_message.assert_awaited() + bot.send_photo.assert_awaited_once() + assert not bot.send_photo.await_args.kwargs.get("caption") + finally: + os.unlink(img) + + +def test_multi_file_keeps_separate_text(monkeypatch: pytest.MonkeyPatch) -> None: + from tools.send_message_tool import _send_telegram + + _no_proxy(monkeypatch) + bot = _make_bot() + _install_telegram_mock(monkeypatch, MagicMock(return_value=bot)) + img = _tmpfile(".png") + img2 = _tmpfile(".jpg") + try: + res = asyncio.run( + _send_telegram("tok", "123", "two pics", media_files=[(img, False), (img2, False)]) + ) + assert res["success"] is True + # Ambiguous caption→file association: text stays a separate message. + bot.send_message.assert_awaited() + assert bot.send_photo.await_count == 2 + for call in bot.send_photo.await_args_list: + assert not call.kwargs.get("caption") + finally: + os.unlink(img) + os.unlink(img2) diff --git a/tests/tools/test_terminal_output_transform_hook.py b/tests/tools/test_terminal_output_transform_hook.py index d5ab593420b0..dd52222ceb7d 100644 --- a/tests/tools/test_terminal_output_transform_hook.py +++ b/tests/tools/test_terminal_output_transform_hook.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock import hermes_cli.plugins as plugins_mod import tools.terminal_tool as terminal_tool_module +from tools.environments.local import LocalEnvironment _UNSET = object() @@ -138,6 +139,61 @@ def test_terminal_output_transform_still_runs_strip_and_redact(monkeypatch, tmp_ assert "abc123def456" not in result["output"] # secret body is gone +def test_large_process_output_is_bounded_before_sudo_and_plugin_hooks( + monkeypatch, tmp_path +): + limit = 10_000 + monkeypatch.setattr("tools.tool_output_limits.get_max_bytes", lambda: limit) + monkeypatch.setattr( + terminal_tool_module, "_get_env_config", lambda: _make_env_config(tmp_path) + ) + monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None) + monkeypatch.setattr( + terminal_tool_module, + "_check_all_guards", + lambda *_args, **_kwargs: {"approved": True}, + ) + + sudo_input_lengths = [] + hook_inputs = [] + + def _sudo_spy(output): + sudo_input_lengths.append(len(output)) + return False + + def _hook_spy(hook_name, **kwargs): + if hook_name == "transform_terminal_output": + hook_inputs.append(kwargs["output"]) + return [] + + monkeypatch.setattr( + terminal_tool_module, "_sudo_wrong_password_failure", _sudo_spy + ) + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", _hook_spy) + + env = LocalEnvironment(cwd=str(tmp_path), timeout=10) + monkeypatch.setitem(terminal_tool_module._active_environments, "default", env) + monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0) + try: + command = ( + "python3 -c \"import sys; " + "sys.stdout.write('HEAD-SENTINEL\\n' + 'x' * 2000000 + " + "'\\nTAIL-SENTINEL')\"" + ) + result = json.loads(terminal_tool_module.terminal_tool(command=command)) + finally: + env.cleanup() + + assert sudo_input_lengths + assert max(sudo_input_lengths) <= limit + assert len(hook_inputs) == 1 + assert len(hook_inputs[0]) <= limit + assert hook_inputs[0].startswith("HEAD-SENTINEL") + assert hook_inputs[0].endswith("TAIL-SENTINEL") + assert "[OUTPUT TRUNCATED" in hook_inputs[0] + assert len(result["output"]) <= limit + + def test_terminal_output_transform_hook_exception_falls_back(monkeypatch, tmp_path): def _raise(*_args, **_kwargs): raise RuntimeError("boom") diff --git a/tests/tools/test_terminal_task_cwd.py b/tests/tools/test_terminal_task_cwd.py index 85817fa53351..fffe3c0bbe4e 100644 --- a/tests/tools/test_terminal_task_cwd.py +++ b/tests/tools/test_terminal_task_cwd.py @@ -40,7 +40,7 @@ def test_foreground_command_uses_registered_task_cwd_for_existing_environment(mo result = json.loads(terminal_tool.terminal_tool(command="pwd", task_id=task_id)) assert result["exit_code"] == 0 - assert calls == [("pwd", {"timeout": 60, "cwd": "/workspace/acp"})] + assert calls == [("pwd", {"timeout": 60, "cwd": "/workspace/acp", "bounded_capture": True})] def test_explicit_workdir_still_wins_over_registered_task_cwd(monkeypatch): @@ -73,7 +73,7 @@ def test_explicit_workdir_still_wins_over_registered_task_cwd(monkeypatch): ) assert result["exit_code"] == 0 - assert calls == [{"timeout": 60, "cwd": "/explicit/workdir"}] + assert calls == [{"timeout": 60, "cwd": "/explicit/workdir", "bounded_capture": True}] def test_foreground_command_prefers_live_env_cwd_over_init_time_cwd(monkeypatch): @@ -104,7 +104,7 @@ def test_foreground_command_prefers_live_env_cwd_over_init_time_cwd(monkeypatch) result = json.loads(terminal_tool.terminal_tool(command="pwd", task_id=task_id)) assert result["exit_code"] == 0 - assert calls == [("pwd", {"timeout": 60, "cwd": "/workspace/live"})] + assert calls == [("pwd", {"timeout": 60, "cwd": "/workspace/live", "bounded_capture": True})] def test_background_command_prefers_live_env_cwd_over_init_time_cwd(monkeypatch): @@ -223,6 +223,89 @@ def test_registering_non_cwd_override_leaves_live_env_cwd_untouched(monkeypatch) assert fake_env.cwd == "/workspace/keep" +def test_stale_env_cwd_from_different_session_is_ignored(monkeypatch): + """A different session's `cd` left env.cwd pointing at its checkout. + + The terminal env is shared (collapsed to "default"), so env.cwd tracks the + LAST session that ran a command. When session B claims the env after + session A left it in A's worktree, the first command must NOT run in A's + leftover cwd — it must fall through to the config/override cwd (this + session's own workspace). + """ + calls = [] + + class FakeEnv: + env = {} + cwd = "/home/user/src/hermes-desktop-tipc/apps/desktop" + cwd_owner = "session-A-key" + + def execute(self, command, **kwargs): + calls.append((command, kwargs)) + return {"output": "ok", "returncode": 0} + + task_id = "session-B" + monkeypatch.setattr(terminal_tool, "_active_environments", {"default": FakeEnv()}) + monkeypatch.setattr(terminal_tool, "_last_activity", {}) + monkeypatch.setattr(terminal_tool, "_task_env_overrides", {}) + monkeypatch.setattr(terminal_tool, "_get_env_config", lambda: _minimal_terminal_config(cwd="/home/user/src/hermes-agent")) + monkeypatch.setattr(terminal_tool, "_start_cleanup_thread", lambda: None) + monkeypatch.setattr(terminal_tool, "_resolve_container_task_id", lambda value: "default") + monkeypatch.setattr( + terminal_tool, + "_check_all_guards", + lambda command, env_type, **kwargs: {"approved": True}, + ) + + result = json.loads(terminal_tool.terminal_tool(command="pwd", task_id=task_id)) + + assert result["exit_code"] == 0 + # The command must run in the config cwd (hermes-agent), NOT the stale + # env.cwd left by session A (hermes-desktop-tipc). + assert calls == [("pwd", {"timeout": 60, "cwd": "/home/user/src/hermes-agent", "bounded_capture": True})] + + +def test_same_session_env_cwd_is_trusted_after_first_claim(monkeypatch): + """Once a session has claimed the env, subsequent commands trust env.cwd. + + The prev_owner check only rejects env.cwd when a DIFFERENT session owned it + before this call. After the first command (which claims ownership), + subsequent calls in the same session should trust the live env.cwd so that + in-session `cd` state survives. + """ + calls = [] + + class FakeEnv: + env = {} + cwd = "/workspace/deep" + cwd_owner = "session-X" + + def execute(self, command, **kwargs): + calls.append((command, kwargs)) + return {"output": "ok", "returncode": 0} + + env = FakeEnv() + task_id = "session-X" + monkeypatch.setattr(terminal_tool, "_active_environments", {"default": env}) + monkeypatch.setattr(terminal_tool, "_last_activity", {}) + monkeypatch.setattr(terminal_tool, "_task_env_overrides", {}) + monkeypatch.setattr(terminal_tool, "_get_env_config", lambda: _minimal_terminal_config(cwd="/workspace/config")) + monkeypatch.setattr(terminal_tool, "_start_cleanup_thread", lambda: None) + monkeypatch.setattr(terminal_tool, "_resolve_container_task_id", lambda value: "default") + monkeypatch.setattr( + terminal_tool, + "_check_all_guards", + lambda command, env_type, **kwargs: {"approved": True}, + ) + + # First call: env was owned by "session-X" (same session_key since + # get_current_session_key falls back to task_id). prev_owner == current + # session, so env.cwd is trusted. + result = json.loads(terminal_tool.terminal_tool(command="pwd", task_id=task_id)) + + assert result["exit_code"] == 0 + assert calls == [("pwd", {"timeout": 60, "cwd": "/workspace/deep", "bounded_capture": True})] + + def test_safe_getcwd_returns_real_cwd(monkeypatch): monkeypatch.setattr(terminal_tool.os, "getcwd", lambda: "/home/user/project") assert terminal_tool._safe_getcwd() == "/home/user/project" diff --git a/tests/tools/test_transcription_deepinfra.py b/tests/tools/test_transcription_deepinfra.py new file mode 100644 index 000000000000..39952147dad5 --- /dev/null +++ b/tests/tools/test_transcription_deepinfra.py @@ -0,0 +1,66 @@ +"""Tests for the DeepInfra STT provider. + +``_transcribe_deepinfra`` is a thin shim that resolves credentials/model +then delegates to ``_transcribe_openai``. These two tests pin the +STT-specific gating (so an unset DEEPINFRA_API_KEY refuses dispatch) and +the delegation happy path; shared catalog/tag-filter behavior is covered +in ``tests/hermes_cli/test_api_key_providers.py``. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _isolation(monkeypatch): + import hermes_cli.models as _models_mod + monkeypatch.setattr(_models_mod, "_deepinfra_catalog_cache", {}) + yield + + +def test_get_provider_gating_keys_on_deepinfra_api_key(monkeypatch): + """Explicit-provider gate: DEEPINFRA_API_KEY presence flips ``deepinfra`` on/off.""" + monkeypatch.delenv("DEEPINFRA_API_KEY", raising=False) + from tools.transcription_tools import _get_provider + assert _get_provider({"provider": "deepinfra"}) == "none" + monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key") + assert _get_provider({"provider": "deepinfra"}) == "deepinfra" + + +def test_delegates_to_openai_handler_with_deepinfra_creds(monkeypatch, tmp_path): + """Happy path: pinned model → openai SDK invoked with DeepInfra base_url + key, + and the response carries ``provider="deepinfra"`` (not openai).""" + monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key") + audio = tmp_path / "speech.wav" + audio.write_bytes(b"\x00" * 16) + + captured: dict = {} + + class _FakeClient: + def __init__(self, api_key=None, base_url=None, timeout=None, max_retries=None): + captured["api_key"] = api_key + captured["base_url"] = base_url + transcriptions = MagicMock() + transcriptions.create = MagicMock(return_value=MagicMock(text="ok")) + self.audio = MagicMock(transcriptions=transcriptions) + def close(self): + pass + + fake_openai = MagicMock() + fake_openai.OpenAI = _FakeClient + fake_openai.APIError = Exception + fake_openai.APIConnectionError = ConnectionError + fake_openai.APITimeoutError = TimeoutError + + with patch.dict("sys.modules", {"openai": fake_openai}), \ + patch("tools.transcription_tools._load_stt_config", return_value={}): + from tools.transcription_tools import _transcribe_deepinfra + result = _transcribe_deepinfra(str(audio), "vendor/test-stt") + + assert result["success"] is True + assert result["provider"] == "deepinfra" + assert "deepinfra" in captured["base_url"] + assert captured["api_key"] == "test-key" diff --git a/tests/tools/test_tts_command_providers.py b/tests/tools/test_tts_command_providers.py index e3242274a005..616a88d7b88d 100644 --- a/tests/tools/test_tts_command_providers.py +++ b/tests/tools/test_tts_command_providers.py @@ -493,6 +493,9 @@ class TestTextToSpeechToolWithCommandProvider: class TestCheckTtsRequirements: def test_configured_command_provider_satisfies_requirement(self): - cfg = {"providers": {"x": {"type": "command", "command": "echo x"}}} + cfg = { + "provider": "x", + "providers": {"x": {"type": "command", "command": "echo x"}}, + } with patch("tools.tts_tool._load_tts_config", return_value=cfg): assert check_tts_requirements() is True diff --git a/tests/tools/test_tts_deepinfra.py b/tests/tools/test_tts_deepinfra.py new file mode 100644 index 000000000000..7f0b7ad43d5d --- /dev/null +++ b/tests/tools/test_tts_deepinfra.py @@ -0,0 +1,82 @@ +"""Tests for the DeepInfra TTS provider. + +``_generate_deepinfra_tts`` is a thin shim that resolves credentials/model +then delegates to ``_generate_openai_tts``. These two tests pin the +delegation happy path and the no-hardcoded-fallback contract; shared +infrastructure (catalog fetch + tag filter) is covered in +``tests/hermes_cli/test_api_key_providers.py``. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _isolation(monkeypatch): + import hermes_cli.models as _models_mod + monkeypatch.setattr(_models_mod, "_deepinfra_catalog_cache", {}) + monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key") + yield + + +def test_raises_when_no_model_resolvable(monkeypatch, tmp_path): + """No-fallback contract: empty config + unreachable catalog → ValueError.""" + import urllib.request + monkeypatch.setattr( + urllib.request, "urlopen", + lambda *a, **kw: (_ for _ in ()).throw(Exception("offline")), + ) + from tools.tts_tool import _generate_deepinfra_tts + with pytest.raises(ValueError, match="No DeepInfra TTS model available"): + _generate_deepinfra_tts("hi", str(tmp_path / "out.mp3"), {}) + + +def test_delegates_to_openai_handler_with_deepinfra_creds(monkeypatch, tmp_path): + """Happy path: pinned model → openai SDK invoked with DeepInfra base_url + key.""" + captured: dict = {} + + class _FakeClient: + def __init__(self, api_key=None, base_url=None): + captured["api_key"] = api_key + captured["base_url"] = base_url + speech = MagicMock() + speech.create = MagicMock(return_value=MagicMock(stream_to_file=lambda p: None)) + self.audio = MagicMock(speech=speech) + def close(self): + pass + + with patch("tools.tts_tool._import_openai_client", return_value=_FakeClient): + from tools.tts_tool import _generate_deepinfra_tts + _generate_deepinfra_tts( + "hello", str(tmp_path / "out.mp3"), + {"deepinfra": {"model": "vendor/test-tts"}}, + ) + + assert "deepinfra" in captured["base_url"] + assert captured["api_key"] == "test-key" + + +def test_requirements_follow_explicit_deepinfra_provider(monkeypatch): + from tools import tts_tool + + monkeypatch.setattr( + tts_tool, + "_load_tts_config", + lambda: {"provider": "deepinfra", "deepinfra": {}}, + ) + monkeypatch.setattr(tts_tool, "_import_openai_client", lambda: object) + + assert tts_tool.check_tts_requirements() is True + + +def test_unselected_cloud_credentials_do_not_expose_edge_tool(monkeypatch): + from tools import tts_tool + + monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: {}) + monkeypatch.setattr(tts_tool, "_import_edge_tts", MagicMock(side_effect=ImportError)) + monkeypatch.setenv("OPENAI_API_KEY", "unselected-key") + + assert tts_tool.check_tts_requirements() is False diff --git a/tests/tools/test_tts_dotenv_fallback.py b/tests/tools/test_tts_dotenv_fallback.py index 0a4ea5a8ac2e..979890486f49 100644 --- a/tests/tools/test_tts_dotenv_fallback.py +++ b/tests/tools/test_tts_dotenv_fallback.py @@ -269,6 +269,8 @@ class TestRegressionGuard: with patch( "hermes_cli.config.load_env", return_value={"MINIMAX_API_KEY": "dotenv-secret"}, + ), patch.object( + tts_tool, "_load_tts_config", return_value={"provider": "minimax"} ), patch.object(tts_tool, "_import_edge_tts", side_effect=ImportError), \ patch.object(tts_tool, "_import_elevenlabs", side_effect=ImportError), \ patch.object(tts_tool, "_import_openai_client", side_effect=ImportError), \ diff --git a/tests/tools/test_tts_gemini.py b/tests/tools/test_tts_gemini.py index 85254649d53d..1a8bde7cc8a9 100644 --- a/tests/tools/test_tts_gemini.py +++ b/tests/tools/test_tts_gemini.py @@ -115,6 +115,19 @@ class TestGenerateGeminiTts: # Audio payload should match the PCM we put in assert data[44:] == fake_pcm_bytes + def test_x_goog_api_client_header_is_set(self, tmp_path, monkeypatch, mock_gemini_response): + """Gemini TTS requests should include Hermes client context.""" + from hermes_cli import __version__ + from tools.tts_tool import _generate_gemini_tts + + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + + with patch("requests.post", return_value=mock_gemini_response) as mock_post: + _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {}) + + headers = mock_post.call_args[1]["headers"] + assert headers["X-Goog-Api-Client"] == f"hermes-agent/{__version__}" + def test_default_voice_and_model(self, tmp_path, monkeypatch, mock_gemini_response): from tools.tts_tool import ( DEFAULT_GEMINI_TTS_MODEL, @@ -255,6 +268,22 @@ class TestGenerateGeminiTts: _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {}) assert mock_post.call_args[0][0].startswith("https://custom-gemini.example.com/v1beta/") + assert "X-Goog-Api-Client" not in mock_post.call_args[1]["headers"] + + def test_lookalike_base_url_omits_client_context( + self, tmp_path, monkeypatch, mock_gemini_response + ): + from tools.tts_tool import _generate_gemini_tts + + lookalike = "https://generativelanguage.googleapis.com.evil.example/v1beta" + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + monkeypatch.setenv("GEMINI_BASE_URL", lookalike) + + with patch("requests.post", return_value=mock_gemini_response) as mock_post: + _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {}) + + assert mock_post.call_args[0][0].startswith(f"{lookalike}/") + assert "X-Goog-Api-Client" not in mock_post.call_args[1]["headers"] def test_persona_prompt_file_appends_labeled_transcript( self, tmp_path, monkeypatch, mock_gemini_response @@ -447,5 +476,8 @@ class TestGeminiInCheckRequirements: raise ImportError("simulated") return real_import(name, *args, **kwargs) - with patch("builtins.__import__", side_effect=fake_import): + with patch( + "tools.tts_tool._load_tts_config", + return_value={"provider": "gemini"}, + ), patch("builtins.__import__", side_effect=fake_import): assert check_tts_requirements() is True diff --git a/tests/tools/test_tts_mistral.py b/tests/tools/test_tts_mistral.py index 6e98946b6c0c..03735ff85f31 100644 --- a/tests/tools/test_tts_mistral.py +++ b/tests/tools/test_tts_mistral.py @@ -204,7 +204,10 @@ class TestCheckTtsRequirementsMistral: from tools.tts_tool import check_tts_requirements monkeypatch.setenv("MISTRAL_API_KEY", "test-key") - with patch("tools.tts_tool._import_edge_tts", side_effect=ImportError), \ + with patch( + "tools.tts_tool._load_tts_config", + return_value={"provider": "mistral"}, + ), patch("tools.tts_tool._import_edge_tts", side_effect=ImportError), \ patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError), \ patch("tools.tts_tool._import_openai_client", side_effect=ImportError), \ patch("tools.tts_tool._check_neutts_available", return_value=False): diff --git a/tests/tools/test_tts_piper.py b/tests/tools/test_tts_piper.py index 78567adf9bbc..9de07d70c40d 100644 --- a/tests/tools/test_tts_piper.py +++ b/tests/tools/test_tts_piper.py @@ -376,6 +376,7 @@ class TestTextToSpeechToolWithPiper: class TestCheckTtsRequirementsPiper: def test_piper_install_satisfies_requirements(self, monkeypatch): # Drop every other provider so we can isolate the piper signal. + monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: {"provider": "piper"}) monkeypatch.setattr(tts_tool, "_import_edge_tts", lambda: (_ for _ in ()).throw(ImportError())) monkeypatch.setattr(tts_tool, "_import_elevenlabs", lambda: (_ for _ in ()).throw(ImportError())) monkeypatch.setattr(tts_tool, "_import_openai_client", lambda: (_ for _ in ()).throw(ImportError())) diff --git a/tests/tools/test_video_generation_dynamic_schema.py b/tests/tools/test_video_generation_dynamic_schema.py index a9565dab3e92..e3049d54dfa8 100644 --- a/tests/tools/test_video_generation_dynamic_schema.py +++ b/tests/tools/test_video_generation_dynamic_schema.py @@ -88,7 +88,10 @@ class TestDynamicSchemaBuilder: from tools.video_generation_tool import _build_dynamic_video_schema desc = _build_dynamic_video_schema()["description"] - assert "No video backend is configured" in desc + # No provider configured AND none available → description says so. The + # wording reflects the *resolved* active provider (mirrors execution), + # so it reads "available" rather than "configured". + assert "No video backend is available" in desc assert "hermes tools" in desc def test_generic_description_keeps_edit_extend_out_of_surface(self, cfg_home): diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py index 535f930e0808..aac919b7a325 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -548,6 +548,14 @@ class TestCheckWebApiKey: self._managed_patchers = [ patch("tools.web_tools.managed_nous_tools_enabled", return_value=True), patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True), + # ddgs availability is package-presence driven and the plugin + # registry can hold an available ddgs provider. Neutralize both + # fallback surfaces so this class only exercises env-key/gateway + # resolution — otherwise these tests flip on machines where the + # optional ``ddgs`` package is installed (dev venvs) vs CI. + patch("tools.web_tools._ddgs_package_importable", return_value=False), + patch("agent.web_search_registry.get_active_search_provider", return_value=None), + patch("agent.web_search_registry.get_active_extract_provider", return_value=None), ] for p in self._managed_patchers: p.start() @@ -568,6 +576,22 @@ class TestCheckWebApiKey: from tools.web_tools import check_web_api_key assert check_web_api_key() is True + def test_null_backend_value_does_not_crash(self): + # config.yaml with ``web:\n backend:`` yields backend=None. The gate + # must not raise AttributeError on None.lower() — mirrors _get_backend. + with patch("tools.web_tools._load_web_config", return_value={"backend": None}): + from tools.web_tools import check_web_api_key + assert check_web_api_key() is False + + def test_null_web_section_does_not_crash(self): + # config.yaml with a present-but-null ``web:`` section makes the raw + # ``.get("web", {})`` return None; _load_web_config must still yield a + # dict so no caller does None.get(...). + with patch("hermes_cli.config.load_config", return_value={"web": None}): + from tools.web_tools import _load_web_config, check_web_api_key + assert _load_web_config() == {} + assert check_web_api_key() is False + def test_firecrawl_key_only(self): with patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}): from tools.web_tools import check_web_api_key diff --git a/tests/tools/test_web_tools_dict_urls.py b/tests/tools/test_web_tools_dict_urls.py new file mode 100644 index 000000000000..d58f06954427 --- /dev/null +++ b/tests/tools/test_web_tools_dict_urls.py @@ -0,0 +1,116 @@ +"""Regression tests for model-forwarded web-search result objects.""" + +import json + +import pytest + +from agent import web_search_registry +from agent.web_search_provider import WebSearchProvider +from tools import web_tools + + +class _FakeExtractProvider(WebSearchProvider): + def __init__(self) -> None: + self.received_urls: list[str] = [] + + @property + def name(self) -> str: + return "dict-url-test" + + @property + def display_name(self) -> str: + return "Dict URL Test" + + def is_available(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + async def extract(self, urls, **kwargs): + self.received_urls.extend(urls) + return [ + {"url": url, "title": "", "content": "ok"} + for url in urls + ] + + +@pytest.fixture +def extract_provider(monkeypatch): + with web_search_registry._lock: + previous = dict(web_search_registry._providers) + web_search_registry._providers.clear() + + provider = _FakeExtractProvider() + web_search_registry.register_provider(provider) + monkeypatch.setattr(web_tools, "_ensure_web_plugins_loaded", lambda: None) + monkeypatch.setattr( + web_tools, + "_load_web_config", + lambda: {"extract_backend": provider.name}, + ) + + async def _safe(_url): + return True + + monkeypatch.setattr(web_tools, "async_is_safe_url", _safe) + yield provider + + with web_search_registry._lock: + web_search_registry._providers.clear() + web_search_registry._providers.update(previous) + + +@pytest.mark.asyncio +async def test_web_extract_dispatches_urls_from_search_result_objects(extract_provider): + result = json.loads(await web_tools.web_extract_tool([ + {"url": "https://example.com/a", "title": "A"}, + {"href": "https://example.org/b"}, + ])) + + assert extract_provider.received_urls == [ + "https://example.com/a", + "https://example.org/b", + ] + assert [entry["url"] for entry in result["results"]] == extract_provider.received_urls + + +@pytest.mark.asyncio +async def test_web_extract_reports_invalid_items_without_dispatching_them(extract_provider): + result = json.loads(await web_tools.web_extract_tool([ + {"url": "https://example.com/good"}, + {"title": "missing URL"}, + {"url": 123}, + None, + ])) + + assert extract_provider.received_urls == ["https://example.com/good"] + assert [entry["url"] for entry in result["results"]] == [ + "https://example.com/good", + "", + "", + "", + ] + errors = [entry["error"] for entry in result["results"] if entry["error"]] + assert errors == [ + "Invalid URL item at index 1: expected a URL string or an object " + "with a string 'url' or 'href' field", + "Invalid URL item at index 2: expected a URL string or an object " + "with a string 'url' or 'href' field", + "Invalid URL item at index 3: expected a URL string or an object " + "with a string 'url' or 'href' field", + ] + + +def test_web_extract_registry_dispatch_accepts_search_result_objects( + extract_provider, +): + """The model-facing registry path preserves object URLs through dispatch.""" + raw = web_tools.registry.dispatch("web_extract", { + "urls": [{"url": "https://example.net/from-registry", "title": "R"}], + }) + assert isinstance(raw, str) + result = json.loads(raw) + + assert extract_provider.received_urls == ["https://example.net/from-registry"] + assert result["results"][0]["url"] == "https://example.net/from-registry" diff --git a/tests/tools/test_whatsapp_send_message_media.py b/tests/tools/test_whatsapp_send_message_media.py index d1fac4495e0f..eef890edf98d 100644 --- a/tests/tools/test_whatsapp_send_message_media.py +++ b/tests/tools/test_whatsapp_send_message_media.py @@ -219,3 +219,78 @@ def test_text_only_unchanged_behavior(): "message_id": "t1", } assert len(calls) == 1 and calls[0][0].endswith("/send") + + +def test_caption_rides_media_no_separate_text_send(): + """MEDIA: caption -> single /send-media with caption, no /send.""" + img = _tmpfile(".png") + try: + session_ctx, calls = _session_with([_resp(200, {"messageId": "m1"})]) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + "12345", + "", + media_files=[(img, False)], + caption="2-bedroom floor plan", + ) + ) + assert res["success"] is True + # No separate /send — exactly one /send-media carrying the caption. + assert len(calls) == 1 + assert calls[0][0].endswith("/send-media") + assert calls[0][1]["caption"] == "2-bedroom floor plan" + assert calls[0][1]["mediaType"] == "image" + finally: + os.unlink(img) + + +def test_caption_ignored_for_multi_file_send(): + """A caption never rides a multi-file send (association is ambiguous).""" + img = _tmpfile(".png") + img2 = _tmpfile(".jpg") + try: + session_ctx, calls = _session_with( + [_resp(200, {"messageId": "m1"}), _resp(200, {"messageId": "m2"})] + ) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + "12345", + "", + media_files=[(img, False), (img2, False)], + caption="should be ignored", + ) + ) + assert res["success"] is True + media_calls = [c for c in calls if c[0].endswith("/send-media")] + assert len(media_calls) == 2 + assert all("caption" not in c[1] for c in media_calls) + finally: + os.unlink(img) + os.unlink(img2) + + +def test_missing_captioned_file_falls_back_to_text(): + """If the single captioned file is missing, the caption is delivered as a + plain /send message rather than being silently lost (W1).""" + session_ctx, calls = _session_with([_resp(200, {"messageId": "t1"})]) + with patch("aiohttp.ClientSession", return_value=session_ctx): + res = asyncio.run( + _standalone_send( + _pconfig(), + "12345", + "", + media_files=[("/no/such/file.png", False)], + caption="floor plan", + ) + ) + # The send still surfaces the missing-file error... + assert "error" in res + assert "not found" in res["error"] + # ...but the caption text was delivered on its own first. + assert len(calls) == 1 + assert calls[0][0].endswith("/send") + assert calls[0][1]["message"] == "floor plan" diff --git a/tests/tui_gateway/test_finalize_session_persist.py b/tests/tui_gateway/test_finalize_session_persist.py index e1fe7ea53728..c927488de575 100644 --- a/tests/tui_gateway/test_finalize_session_persist.py +++ b/tests/tui_gateway/test_finalize_session_persist.py @@ -54,11 +54,11 @@ def _make_session(agent=None, history=None, session_key="test_key_001"): class TestFinalizeSessionPersist: """Verify _finalize_session flushes messages via _persist_session.""" - def test_persist_called_with_history(self): - """History from session is passed to agent._persist_session. - - When _session_messages is None (not yet set by any turn), - the session["history"] is used as the snapshot. + def test_no_session_messages_skips_persist(self): + """When _session_messages is empty/None the agent processed nothing + this session, so there is nothing new to flush. Falling back to + session["history"] here re-appended already-durable resumed rows as + duplicates, so finalize must NOT write in that case. """ from tui_gateway.server import _finalize_session @@ -66,20 +66,16 @@ class TestFinalizeSessionPersist: {"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi there"}, ] - agent = _make_agent() + agent = _make_agent() # _session_messages is None session = _make_session(agent=agent, history=history) _finalize_session(session, end_reason="test") - agent._persist_session.assert_called_once() - # snapshot = history (since _session_messages is None) - called_with = agent._persist_session.call_args[0][0] - assert called_with == history - # conversation_history kwarg passed for correct flush indexing - assert agent._persist_session.call_args[1].get("conversation_history") == history + agent._persist_session.assert_not_called() - def test_persist_uses_session_messages_when_available(self): - """agent._session_messages takes priority over session['history'].""" + def test_persist_uses_session_messages(self): + """agent._session_messages is flushed via the marker-based dedup path + (no conversation_history — passing the same list neutered the write).""" from tui_gateway.server import _finalize_session history = [{"role": "user", "content": "old"}] @@ -93,10 +89,10 @@ class TestFinalizeSessionPersist: _finalize_session(session) - agent._persist_session.assert_called_once() - called_with = agent._persist_session.call_args[0][0] - assert called_with == session_msgs # _session_messages wins - assert agent._persist_session.call_args[1].get("conversation_history") == history + agent._persist_session.assert_called_once_with(session_msgs) + # conversation_history must NOT be passed — it aliases the snapshot and + # makes _flush_messages_to_session_db skip every message. + assert "conversation_history" not in agent._persist_session.call_args[1] def test_commit_memory_still_called(self): """Existing memory commit path is preserved.""" @@ -158,6 +154,7 @@ class TestFinalizeSessionPersist: from tui_gateway.server import _finalize_session agent = _make_agent() + agent._session_messages = [{"role": "user", "content": "x"}] agent._persist_session.side_effect = RuntimeError("db is down") session = _make_session( agent=agent, @@ -165,6 +162,7 @@ class TestFinalizeSessionPersist: ) _finalize_session(session) # must not raise + agent._persist_session.assert_called_once() # commit_memory_session should still be called agent.commit_memory_session.assert_called_once() @@ -184,6 +182,154 @@ class TestFinalizeSessionPersist: mock_db.end_session.assert_called_once_with("sess_123", "test") +class TestFinalizeSessionPersistE2E: + """End-to-end: _finalize_session must actually land unflushed turns in + state.db on disconnect/restart. + + The mock-based tests above assert that _persist_session is *called*, but a + call whose arguments neuter the underlying flush persists nothing. These + tests drive the REAL AIAgent flush against a REAL SessionDB, reproducing + the "conversation contains many events yet is absent from state.db across + disconnect/restart" symptom. + """ + + @staticmethod + def _real_agent(db, session_id, session_messages): + from run_agent import AIAgent + + agent = object.__new__(AIAgent) + agent._session_db = db + agent._session_db_created = True + agent.session_id = session_id + agent.platform = "tui" + agent.model = "test-model" + agent._session_messages = session_messages + agent._last_flushed_db_idx = 0 + agent._flushed_db_message_ids = set() + agent._flushed_db_message_session_id = None + agent._persist_disabled = False + agent._cached_system_prompt = None + agent._session_init_model_config = None + agent._parent_session_id = None + agent._session_json_enabled = False + agent.quiet_mode = True + # commit_memory_session runs heavy machinery we don't exercise here. + agent.commit_memory_session = lambda *a, **k: None + return agent + + def test_unflushed_turn_survives_disconnect(self, tmp_path, monkeypatch): + """A completed turn whose transcript flush did NOT durably persist + (messages live only in agent._session_messages / session['history'], + never written to the DB) must be flushed to state.db when the WS + disconnect tears the session down.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + from hermes_state import SessionDB + import tui_gateway.server as srv + + db = SessionDB(db_path=tmp_path / "state.db") + session_id = "sess-unflushed" + db.create_session(session_id=session_id, source="tui") + monkeypatch.setattr(srv, "_get_db", lambda: db) + + # The live turn list that became session["history"] AND + # agent._session_messages (same object), but was never persisted. + turn = [ + {"role": "user", "content": "scan the repo and summarise"}, + {"role": "assistant", "content": "Here is the summary…"}, + {"role": "user", "content": "now open a PR"}, + {"role": "assistant", "content": "PR opened."}, + ] + agent = self._real_agent(db, session_id, turn) + session = _make_session(agent=agent, history=turn, session_key=session_id) + + assert db.get_messages_as_conversation(session_id) == [] + + srv._finalize_session(session, end_reason="ws_disconnect") + + after = db.get_messages_as_conversation(session_id) + contents = [m.get("content") for m in after] + assert len(after) == 4, after + assert any("scan the repo" in (c or "") for c in contents), contents + assert any("PR opened" in (c or "") for c in contents), contents + + def test_resumed_session_not_reflushed_as_duplicates(self, tmp_path, monkeypatch): + """A resumed session torn down before any new turn (its transcript is + already durable in the DB) must NOT re-append duplicate rows.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + from hermes_state import SessionDB + import tui_gateway.server as srv + + db = SessionDB(db_path=tmp_path / "state.db") + session_id = "sess-resumed" + db.create_session(session_id=session_id, source="tui") + loaded = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi there"}, + ] + for m in loaded: + db.append_message(session_id=session_id, role=m["role"], content=m["content"]) + monkeypatch.setattr(srv, "_get_db", lambda: db) + + # Resumed session: history hydrated from the DB, no turn ran, so the + # agent processed nothing this session. + agent = self._real_agent(db, session_id, []) + session = _make_session(agent=agent, history=loaded, session_key=session_id) + + srv._finalize_session(session, end_reason="ws_disconnect") + + after = db.get_messages_as_conversation(session_id) + assert len(after) == 2, after + + def test_resumed_then_run_turn_not_duplicated(self, tmp_path, monkeypatch): + """A resumed session that RUNS a turn must not have its loaded (durable) + prefix re-appended by finalize. + + This exercises the exact path the ``conversation_history`` argument used + to guard: the in-turn flush stamps the loaded prefix with + ``_DB_PERSISTED_MARKER`` (recognising it as durable), so the marker-only + finalize flush skips it. Without that stamping — or if finalize wrote a + markerless copy — the durable prefix would double. The + ``_session_messages``-empty test above skips the flush entirely, so it + can't catch a duplicate-write regression; this one drives a real flush. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + from hermes_state import SessionDB + import tui_gateway.server as srv + + db = SessionDB(db_path=tmp_path / "state.db") + session_id = "sess-resume-run" + db.create_session(session_id=session_id, source="tui") + loaded = [ + {"role": "user", "content": "remember: my cat is Mochi"}, + {"role": "assistant", "content": "Noted — Mochi."}, + ] + for m in loaded: + db.append_message(session_id=session_id, role=m["role"], content=m["content"]) + monkeypatch.setattr(srv, "_get_db", lambda: db) + + # Live turn list = loaded prefix (same dicts, as run_conversation copies + # conversation_history) + the new turn. + new_turn = [ + {"role": "user", "content": "what's the cat's name?"}, + {"role": "assistant", "content": "Mochi."}, + ] + messages = list(loaded) + new_turn + agent = self._real_agent(db, session_id, messages) + + # Drive the in-turn flush the way run_conversation does — the loaded + # prefix rides in as conversation_history, so it is recognised durable + # (and marker-stamped) while only the new turn is written. + agent._flush_messages_to_session_db(messages, conversation_history=loaded) + assert len(db.get_messages_as_conversation(session_id)) == 4 + + # WS disconnect → finalize. Must re-append nothing. + session = _make_session(agent=agent, history=messages, session_key=session_id) + srv._finalize_session(session, end_reason="ws_disconnect") + + after = db.get_messages_as_conversation(session_id) + assert len(after) == 4, after + + class TestOnSessionEndHook: """Verify on_session_end plugin hook fires on finalize.""" diff --git a/tests/tui_gateway/test_project_tree.py b/tests/tui_gateway/test_project_tree.py index 0958a7696886..cd0424c5d5a5 100644 --- a/tests/tui_gateway/test_project_tree.py +++ b/tests/tui_gateway/test_project_tree.py @@ -180,6 +180,100 @@ def test_persisted_repo_root_used_when_no_live_probe(): assert _lane_ids(project) == ["/repo::branch::main"] +def test_non_git_cwd_preserves_legacy_workspace_grouping(): + # Before first-class Projects, every non-empty session cwd appeared as a + # workspace even when it was not a git repo. Historical sessions must keep + # that grouping instead of falling through to the flat Sessions list. + legacy = _session("/work/notes", title="Research notes") + + tree = pt.build_tree([], [legacy], [], resolve=lambda _cwd: None, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == ["/work/notes"] + project = tree["projects"][0] + assert project["isAuto"] is True + assert project["label"] == "notes" + assert project["sessionCount"] == 1 + assert _lane_ids(project) == ["/work/notes"] + assert tree["scoped_session_ids"] == [legacy["id"]] + + +def test_non_git_windows_cwd_preserves_legacy_workspace_grouping(): + cwd = r"C:\Users\alice\workspace\notes" + legacy = _session(cwd) + + tree = pt.build_tree([], [legacy], [], resolve=lambda _cwd: None, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == [cwd] + assert tree["projects"][0]["label"] == "notes" + assert tree["scoped_session_ids"] == [legacy["id"]] + + +def test_equivalent_windows_cwds_collapse_into_one_auto_project(): + sessions = [ + _session("C:/work/notes"), + _session(r"c:\WORK\notes"), + _session("C:/work/notes/"), + ] + + tree = pt.build_tree([], sessions, [], resolve=lambda _cwd: None, hydrate=True) + + assert len(tree["projects"]) == 1 + project = tree["projects"][0] + assert project["id"] == "C:/work/notes" + assert project["sessionCount"] == 3 + assert len(project["repos"]) == 1 + assert len(project["repos"][0]["groups"]) == 1 + assert len(project["repos"][0]["groups"][0]["sessions"]) == 3 + + +def test_windows_path_identity_preserves_explicit_project_priority(): + explicit = _project("p_notes", "Notes", ["C:/Work/Notes"]) + session = _session("c:\\work\\notes\\") + + tree = pt.build_tree([explicit], [session], [], resolve=lambda _cwd: None, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == ["p_notes"] + assert tree["projects"][0]["sessionCount"] == 1 + assert tree["scoped_session_ids"] == [session["id"]] + + +def test_wsl_localhost_cwds_collapse_into_one_auto_project(): + # Root-relative WSL spellings (single leading backslash) are Windows paths, + # so case/separator variants collapse instead of spawning duplicate autos. + sessions = [ + _session(r"\wsl.localhost\Ubuntu\home\alice\proj"), + _session("//wsl.localhost/Ubuntu/home/alice/PROJ"), + ] + + tree = pt.build_tree([], sessions, [], resolve=lambda _cwd: None, hydrate=True) + + assert len(tree["projects"]) == 1 + assert tree["projects"][0]["sessionCount"] == 2 + + +def test_wsl_localhost_path_cannot_bypass_explicit_project(): + explicit = _project("p_proj", "Proj", [r"\wsl.localhost\Ubuntu\home\alice\proj"]) + session = _session("//wsl.localhost/Ubuntu/home/alice/PROJ/") + + tree = pt.build_tree([explicit], [session], [], resolve=lambda _cwd: None, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == ["p_proj"] + assert tree["projects"][0]["sessionCount"] == 1 + assert tree["scoped_session_ids"] == [session["id"]] + + +def test_posix_path_identity_remains_case_sensitive(): + explicit = _project("p_notes", "Notes", ["/Work/Notes"]) + session = _session("/work/notes") + + tree = pt.build_tree([explicit], [session], [], resolve=lambda _cwd: None, hydrate=True) + + assert [(p["id"], p["sessionCount"]) for p in tree["projects"]] == [ + ("p_notes", 0), + ("/work/notes", 1), + ] + + def test_explicit_project_claims_sessions_and_beats_auto(): project = _project("p_app", "App", ["/www/app"]) resolve = _resolver( @@ -337,6 +431,41 @@ def test_junk_root_is_dropped_from_the_discovered_tier(): assert tree["projects"] == [] +def test_non_git_cwd_can_group_inside_a_junk_repo_subtree(): + # Repo discovery rejects the full state subtree, but a selected non-git + # descendant may be an intentional workspace carried over from the old UI. + workspace = _session("/home/test/.hermes/workspaces/notes") + + tree = pt.build_tree( + [], + [workspace], + [], + resolve=lambda _cwd: None, + hydrate=True, + is_junk_root=lambda path: path.startswith("/home/test/.hermes"), + is_junk_cwd=lambda path: path in {"/home/test", "/home/test/.hermes"}, + ) + + assert [p["id"] for p in tree["projects"]] == ["/home/test/.hermes/workspaces/notes"] + assert tree["scoped_session_ids"] == [workspace["id"]] + + +def test_broad_default_non_git_cwd_stays_unscoped(): + detached = _session("/home/test/.hermes") + + tree = pt.build_tree( + [], + [detached], + [], + resolve=lambda _cwd: None, + hydrate=True, + is_junk_cwd=lambda path: path in {"/home/test", "/home/test/.hermes"}, + ) + + assert tree["projects"] == [] + assert detached["id"] not in tree["scoped_session_ids"] + + def test_colliding_repo_basenames_disambiguate_labels(): resolve = _resolver( { diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 5aa3daa00ec5..274ad8906be4 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -264,6 +264,48 @@ def test_block_and_respond(capture): assert result[0] == "my_answer" +@pytest.mark.parametrize("event", ["secret.request", "sudo.request"]) +def test_sensitive_prompt_timeout_emits_expiry(capture, event): + server, buf = capture + + assert server._block(event, "s1", {}, timeout=0) == "" + + messages = [json.loads(line) for line in buf.getvalue().splitlines()] + request, expiry = [message["params"] for message in messages] + assert request["type"] == event + assert expiry["type"] == event.removesuffix(".request") + ".expire" + assert expiry["session_id"] == "s1" + assert expiry["payload"]["request_id"] == request["payload"]["request_id"] + + +@pytest.mark.parametrize( + ("method", "value_key"), + [("secret.respond", "value"), ("sudo.respond", "password")], +) +def test_late_sensitive_prompt_response_is_idempotent(server, method, value_key): + response = server.handle_request( + { + "id": "late-response", + "method": method, + "params": {"request_id": "expired-request", value_key: ""}, + } + ) + + assert response["result"] == {"status": "expired"} + + +def test_late_clarify_response_remains_protocol_error(server): + response = server.handle_request( + { + "id": "late-clarify", + "method": "clarify.respond", + "params": {"request_id": "expired-request", "answer": ""}, + } + ) + + assert response["error"]["code"] == 4009 + + def test_clear_pending(server): ev = threading.Event() # _pending values are (sid, Event) tuples @@ -1266,6 +1308,58 @@ def test_slash_exec_rejects_skill_commands(server): assert "skill command" in resp["error"]["message"] +def test_slash_exec_routes_custom_skill_bundle_away_from_worker(server): + """slash.exec expands any custom bundle through command.dispatch.""" + sid = "test-session" + + class Worker: + def __init__(self): + self.calls = [] + + def run(self, cmd): + self.calls.append(cmd) + return f"worker:{cmd}" + + worker = Worker() + server._sessions[sid] = { + "session_key": sid, + "agent": None, + "slash_worker": worker, + } + fake_bundles = { + "/analysis-pack": { + "name": "analysis-pack", + "skills": ["source-check", "claim-audit"], + } + } + fake_msg = ( + '[IMPORTANT: The user has invoked the "analysis-pack" skill bundle.]\n\n' + "User instruction: compare vector databases" + ) + + with patch("agent.skill_bundles.get_skill_bundles", return_value=fake_bundles), \ + patch( + "agent.skill_bundles.build_bundle_invocation_message", + return_value=(fake_msg, ["source-check", "claim-audit"], []), + ): + resp = server.handle_request({ + "id": "r-bundle-slash", + "method": "slash.exec", + "params": { + "command": "analysis-pack compare vector databases", + "session_id": sid, + }, + }) + + assert "error" not in resp + assert resp["result"] == { + "type": "send", + "message": fake_msg, + "notice": "⚡ Loading bundle: analysis-pack (2 skills)", + } + assert worker.calls == [] + + def test_slash_exec_handles_plugin_commands_in_live_gateway(server): """Plugin slash commands return normal slash.exec output without using the worker.""" sid = "test-session" @@ -1418,6 +1512,37 @@ def test_command_dispatch_queue_sends_message(server): assert result["message"] == "tell me about quantum computing" +def test_command_dispatch_builtin_queue_wins_over_colliding_bundle(server): + """A custom /queue bundle must not shadow the built-in /queue command.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid} + fake_bundles = { + "/queue": { + "name": "queue", + "skills": ["source-check", "claim-audit"], + } + } + + with patch("agent.skill_bundles.get_skill_bundles", return_value=fake_bundles), \ + patch("agent.skill_bundles.build_bundle_invocation_message") as build_bundle: + resp = server.handle_request({ + "id": "r-queue-collision", + "method": "command.dispatch", + "params": { + "name": "queue", + "arg": "tell me about quantum computing", + "session_id": sid, + }, + }) + + assert "error" not in resp + assert resp["result"] == { + "type": "send", + "message": "tell me about quantum computing", + } + build_bundle.assert_not_called() + + def test_command_dispatch_queue_requires_arg(server): """command.dispatch /queue without an argument returns an error.""" sid = "test-session" @@ -1619,6 +1744,54 @@ def test_command_dispatch_returns_skill_payload(server): assert result["name"] == "hermes-agent-dev" +def test_command_dispatch_returns_custom_bundle_payload(server): + """command.dispatch preserves bundle arguments in a sendable agent turn.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid} + fake_bundles = { + "/review-suite": { + "name": "review-suite", + "skills": ["source-check", "claim-audit", "enough-research"], + } + } + arg = "audit the migration plan" + fake_msg = ( + '[IMPORTANT: The user has invoked the "review-suite" skill bundle.]\n\n' + f"User instruction: {arg}" + ) + + with patch("agent.skill_bundles.get_skill_bundles", return_value=fake_bundles), \ + patch( + "agent.skill_bundles.build_bundle_invocation_message", + return_value=( + fake_msg, + ["source-check", "claim-audit", "enough-research"], + [], + ), + ) as build_bundle, \ + patch("agent.skill_commands.build_skill_invocation_message") as build_skill, \ + patch.object(server, "_resolve_session_platform", return_value="tui"): + resp = server.handle_request({ + "id": "r-bundle-dispatch", + "method": "command.dispatch", + "params": {"name": "review-suite", "arg": arg, "session_id": sid}, + }) + + assert "error" not in resp + assert resp["result"] == { + "type": "send", + "message": fake_msg, + "notice": "⚡ Loading bundle: review-suite (3 skills)", + } + build_bundle.assert_called_once_with( + "/review-suite", + arg, + task_id=sid, + platform="tui", + ) + build_skill.assert_not_called() + + def test_command_dispatch_awaits_async_plugin_handler(server): async def _handler(arg): return f"async:{arg}" diff --git a/tests/tui_gateway/test_reasoning_config_per_model.py b/tests/tui_gateway/test_reasoning_config_per_model.py new file mode 100644 index 000000000000..d3468ff530a9 --- /dev/null +++ b/tests/tui_gateway/test_reasoning_config_per_model.py @@ -0,0 +1,100 @@ +"""Tests for per-model reasoning_effort override in TUI gateway _load_reasoning_config.""" + +import pytest + +import tui_gateway.server as tui_server + + +class TestTUIPerModelReasoningConfig: + """Test tui_gateway _load_reasoning_config respects per-model overrides.""" + + def test_per_model_override_takes_precedence(self, monkeypatch): + """Per-model override wins over global reasoning_effort.""" + fake_cfg = { + "model": {"default": "anthropic/claude-opus-4.5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": { + "anthropic/claude-opus-4.5": "xhigh", + }, + }, + } + monkeypatch.setattr(tui_server, "_load_cfg", lambda: fake_cfg) + + result = tui_server._load_reasoning_config() + assert result is not None + assert result["enabled"] is True + assert result["effort"] == "xhigh" + + def test_global_fallback_when_no_override(self, monkeypatch): + """Global reasoning_effort applies when no per-model override matches.""" + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": "high", + "reasoning_overrides": { + "anthropic/claude-opus-4.5": "xhigh", + }, + }, + } + monkeypatch.setattr(tui_server, "_load_cfg", lambda: fake_cfg) + + result = tui_server._load_reasoning_config() + assert result is not None + assert result["effort"] == "high" + + def test_spelling_tolerant_match(self, monkeypatch): + """Override matches even with different spelling (provider prefix).""" + fake_cfg = { + "model": {"default": "claude-opus-4.5"}, + "agent": { + "reasoning_effort": "medium", + "reasoning_overrides": { + "anthropic/claude-opus-4.5": "high", # key has prefix, model doesn't + }, + }, + } + monkeypatch.setattr(tui_server, "_load_cfg", lambda: fake_cfg) + + result = tui_server._load_reasoning_config() + assert result is not None + assert result["effort"] == "high" + + def test_parity_with_gateway_loader(self, monkeypatch): + """TUI and gateway loaders return identical results for same config.""" + import gateway.run as gateway_run + + fake_cfg = { + "model": {"default": "openrouter/anthropic/claude-sonnet-4.6"}, + "agent": { + "reasoning_effort": "low", + "reasoning_overrides": { + "claude-sonnet-4.6": "high", + }, + }, + } + monkeypatch.setattr(tui_server, "_load_cfg", lambda: fake_cfg) + monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: fake_cfg) + + tui_result = tui_server._load_reasoning_config() + gw_result = gateway_run.GatewayRunner._load_reasoning_config() + assert tui_result == gw_result + + def test_global_fallback_with_yaml_false(self, monkeypatch): + """YAML boolean False must reach parse_reasoning_effort uncoerced. + + Regression: str(... or "").strip() turned False into "", silently + re-enabling thinking. The raw value must pass through so + parse_reasoning_effort(False) returns {'enabled': False}. + """ + fake_cfg = { + "model": {"default": "gpt-5"}, + "agent": { + "reasoning_effort": False, # YAML boolean, not string + }, + } + monkeypatch.setattr(tui_server, "_load_cfg", lambda: fake_cfg) + + result = tui_server._load_reasoning_config() + assert result is not None + assert result.get("enabled") is False diff --git a/tests/tui_gateway/test_session_platform_resolution.py b/tests/tui_gateway/test_session_platform_resolution.py index da241530604e..dd2b6eda85c6 100644 --- a/tests/tui_gateway/test_session_platform_resolution.py +++ b/tests/tui_gateway/test_session_platform_resolution.py @@ -17,14 +17,18 @@ The resolver helper is import-safe (no heavy module side effects) so it can be unit-tested without spinning up the full gateway. """ -import importlib - import pytest def _reload_resolver(): + # Plain import — every resolver under test reads the env at CALL time, so + # no reload is needed. importlib.reload(tui_gateway.server) would + # re-register the module's atexit hooks (thread-pool shutdown + + # _shutdown_sessions) on every test; duplicated hooks race the stderr + # buffer at interpreter shutdown (Fatal Python error: + # _enter_buffered_busy) — same flake class as PR #34217. Name kept for + # the existing call sites. import tui_gateway.server as _srv - importlib.reload(_srv) return _srv diff --git a/tests/tui_gateway/test_slash_worker_mcp_discovery.py b/tests/tui_gateway/test_slash_worker_mcp_discovery.py new file mode 100644 index 000000000000..82b59fe4b8ee --- /dev/null +++ b/tests/tui_gateway/test_slash_worker_mcp_discovery.py @@ -0,0 +1,105 @@ +"""Integration coverage for profile-local MCP discovery in slash workers.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import queue +import subprocess +import sys +import textwrap +import threading + +import pytest +import yaml + +pytest.importorskip("mcp.server.fastmcp") + + +def test_profile_local_mcp_tool_is_visible_in_slash_worker(tmp_path): + profile_home = tmp_path / "profile-home" + profile_home.mkdir() + marker = "profile-local-61922" + server = tmp_path / "fastmcp_probe.py" + server.write_text( + textwrap.dedent( + f""" + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("profileprobe") + + @mcp.tool() + def hermes_61922_profile_probe() -> str: + return {marker!r} + + if __name__ == "__main__": + mcp.run(transport="stdio") + """ + ), + encoding="utf-8", + ) + (profile_home / "config.yaml").write_text( + yaml.safe_dump( + { + "mcp_servers": { + "profileprobe": { + "enabled": True, + "command": sys.executable, + "args": [str(server)], + } + } + } + ), + encoding="utf-8", + ) + + env = os.environ.copy() + for key in list(env): + if key.endswith("_API_KEY") or key.endswith("_TOKEN"): + env.pop(key) + env["HERMES_HOME"] = str(profile_home) + env["PYTHONPATH"] = str(Path(__file__).resolve().parents[2]) + env["HERMES_SLASH_WATCHDOG_GRACE_S"] = "0" + env["HERMES_SLASH_WATCHDOG_POLL_S"] = "0.05" + proc = subprocess.Popen( + [ + sys.executable, + "-u", + "-m", + "tui_gateway.slash_worker", + "--session-key", + "agent:main:tui:dm:mcp-profile-test", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + cwd=tmp_path, + ) + output: queue.Queue[str] = queue.Queue() + try: + assert proc.stdin is not None + assert proc.stdout is not None + stdout = proc.stdout + threading.Thread( + target=lambda: output.put(stdout.readline()), + daemon=True, + ).start() + proc.stdin.write(json.dumps({"id": 1, "command": "/tools"}) + "\n") + proc.stdin.flush() + try: + line = output.get(timeout=10) + except queue.Empty: + pytest.fail("slash worker produced no /tools response within 10 seconds") + response = json.loads(line) + assert response["ok"] is True + assert "mcp__profileprobe__hermes_61922_profile_probe" in response["output"] + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) diff --git a/tests/tui_gateway/test_undo_command.py b/tests/tui_gateway/test_undo_command.py index fd1dbca59055..cd0f7e5c4e0b 100644 --- a/tests/tui_gateway/test_undo_command.py +++ b/tests/tui_gateway/test_undo_command.py @@ -43,11 +43,16 @@ def server(hermes_home): ): mod = importlib.import_module("tui_gateway.server") yield mod + # Reset module-level session state without re-importing. importlib.reload + # would re-register the module's atexit hooks; duplicated hooks race the + # stderr buffer at interpreter shutdown (Fatal Python error: + # _enter_buffered_busy) — same class as PR #34217. mod._sessions.clear() mod._pending.clear() mod._answers.clear() - mod._methods.clear() - importlib.reload(mod) + # NOTE: _methods is intentionally NOT cleared — it's populated at import + # time and would only repopulate via reload. + mod._db = None @pytest.fixture() diff --git a/tools/approval.py b/tools/approval.py index 05f2eb523cc3..7c58062ed58b 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -17,6 +17,7 @@ import os import re import shlex import sys +import tempfile import threading import time import unicodedata @@ -119,6 +120,53 @@ def _fire_approval_hook(hook_name: str, **kwargs) -> None: logger.debug("Approval hook %s dispatch failed: %s", hook_name, exc) +def _prepare_smart_approval_observer( + *, + command: str, + description: str, + pattern_key: str, + pattern_keys: list[str], + session_key: str, +) -> dict | None: + """Redact and emit the pre-decision smart approval observer hook. + + Redaction is part of observer payload preparation, not approval policy. If + it fails, skip all observability rather than leaking raw data or preventing + the auxiliary LLM from making its decision. + """ + try: + from agent.redact import redact_sensitive_text + + hook_command = redact_sensitive_text(command, force=True) + hook_description = redact_sensitive_text(description, force=True) + except Exception as exc: + logger.debug("Smart approval hook redaction failed: %s", exc) + return + + payload = { + "command": hook_command, + "description": hook_description, + "pattern_key": pattern_key, + "pattern_keys": list(pattern_keys), + "session_key": session_key, + "surface": "smart", + } + _fire_approval_hook("pre_approval_request", **payload) + return payload + + +def _observe_smart_approval_verdict(payload: dict | None, verdict: str) -> None: + """Emit a smart verdict after the auxiliary LLM decision, if safe.""" + if payload is None or verdict not in {"approve", "deny"}: + return + _fire_approval_hook( + "post_approval_response", + **payload, + choice=f"smart_{verdict}", + decided_by="aux_llm", + ) + + def set_current_session_key(session_key: str) -> contextvars.Token[str]: """Bind the active approval session key to the current context.""" @@ -1383,12 +1431,36 @@ def _command_detection_variants(command: str): yield variant +def _is_verification_artifact_cleanup(command: str) -> bool: + """Return whether *command* only removes one Hermes ad-hoc temp script.""" + try: + argv = shlex.split(command, posix=True) + except ValueError: + return False + if len(argv) != 3 or argv[0] != "rm" or argv[1] != "-f": + return False + + operand = argv[2] + temp_dir = os.path.realpath(tempfile.gettempdir()) + basename = os.path.basename(operand) + if operand != os.path.join(temp_dir, basename): + return False + + target = os.path.realpath(operand) + if os.path.dirname(target) != temp_dir: + return False + return re.fullmatch(r"hermes-(?:verify|ad-hoc)-[A-Za-z0-9_.-]+", basename) is not None + + def detect_dangerous_command(command: str) -> tuple: """Check if a command matches any dangerous patterns. Returns: (is_dangerous, pattern_key, description) or (False, None, None) """ + if _is_verification_artifact_cleanup(command): + return (False, None, None) + for command_variant in _command_detection_variants(command): command_lower = command_variant.lower() for pattern_re, description in DANGEROUS_PATTERNS_COMPILED: @@ -1663,16 +1735,21 @@ def save_permanent_allowlist(patterns: set): def prompt_dangerous_approval(command: str, description: str, timeout_seconds: int | None = None, allow_permanent: bool = True, - approval_callback=None) -> str: + approval_callback=None, + *, smart_denied: bool = False) -> str: """Prompt the user to approve a dangerous command (CLI only). Args: allow_permanent: When False, hide the [a]lways option (used when tirith warnings are present, since broad permanent allowlisting is inappropriate for content-level security findings). + smart_denied: When True, this is an owner override of a Smart DENY. + Offer only one-operation approval or denial. approval_callback: Optional callback registered by the CLI for prompt_toolkit integration. Signature: - (command, description, *, allow_permanent=True) -> str. + (command, description, *, allow_permanent=True, + smart_denied=False) -> str. Legacy callback signatures remain + supported when ``smart_denied`` is false. Returns: 'once', 'session', 'always', or 'deny' """ @@ -1689,8 +1766,12 @@ def prompt_dangerous_approval(command: str, description: str, if approval_callback is not None: try: - return approval_callback(display_command, display_description, - allow_permanent=allow_permanent) + callback_kwargs = {"allow_permanent": allow_permanent} + if smart_denied: + callback_kwargs["smart_denied"] = True + return approval_callback( + display_command, display_description, **callback_kwargs + ) except Exception as e: logger.error("Approval callback failed: %s", e, exc_info=True) return "deny" @@ -1732,7 +1813,9 @@ def prompt_dangerous_approval(command: str, description: str, print(f" {t('approval.dangerous_header', description=display_description)}") print(f" {display_command}") print() - if allow_permanent: + if smart_denied: + print(t("approval.choose_smart_deny")) + elif allow_permanent: print(t("approval.choose_long")) else: print(t("approval.choose_short")) @@ -1743,7 +1826,10 @@ def prompt_dangerous_approval(command: str, description: str, def get_input(): try: - prompt = t("approval.prompt_long") if allow_permanent else t("approval.prompt_short") + if smart_denied: + prompt = t("approval.prompt_smart_deny") + else: + prompt = t("approval.prompt_long") if allow_permanent else t("approval.prompt_short") result["choice"] = input(prompt).strip().lower() except (EOFError, OSError): result["choice"] = "" @@ -1757,6 +1843,21 @@ def prompt_dangerous_approval(command: str, description: str, return "deny" choice = result["choice"] + if smart_denied: + choice_map = { + **{ + value: "once" + for value in t("approval.smart_deny_once_inputs").split(",") + }, + **{ + value: "deny" + for value in t("approval.smart_deny_deny_inputs").split(",") + }, + } + decision = choice_map.get(choice, "deny") + print(t("approval.allowed_once" if decision == "once" else "approval.denied")) + return decision + if choice in {'o', 'once'}: print(t("approval.allowed_once")) return "once" @@ -2468,15 +2569,12 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, _drop_entry() return {"resolved": False, "choice": None, "notify_failed": True} - # Block until the user responds or timeout (default 5 min). Poll in short - # slices so we can fire activity heartbeats every ~10s to the agent's - # inactivity tracker — otherwise the gateway watchdog kills the agent - # while the user is still responding. Mirrors _wait_for_process() cadence. - timeout = _get_approval_config().get("gateway_timeout", 300) - try: - timeout = int(timeout) - except (ValueError, TypeError): - timeout = 300 + # Block until the user responds or the canonical approval timeout elapses + # (default 60s). Poll in short slices so we can fire activity heartbeats + # every ~10s to the agent's inactivity tracker — otherwise the gateway + # watchdog kills the agent while the user is still responding. Mirrors + # _wait_for_process() cadence. + timeout = _get_approval_timeout() try: from tools.environments.base import touch_activity_if_due @@ -2741,27 +2839,38 @@ def check_all_command_guards(command: str, env_type: str, # When approvals.mode=smart, ask the aux LLM before prompting the user. # Inspired by OpenAI Codex's Smart Approvals guardian subagent # (openai/codex#13860). + smart_denied_for_owner = False if approval_mode == "smart": combined_desc_for_llm = "; ".join(desc for _, desc, _ in warnings) + observer_payload = _prepare_smart_approval_observer( + command=command, + description=combined_desc_for_llm, + pattern_key=warnings[0][0], + pattern_keys=[key for key, _, _ in warnings], + session_key=session_key, + ) verdict = _smart_approve(command, combined_desc_for_llm) + _observe_smart_approval_verdict(observer_payload, verdict) if verdict == "approve": - # Auto-approve and grant session-level approval for these patterns - for key, _, _ in warnings: - approve_session(session_key, key) + # Approve this command only. Pattern-level persistence would let one + # benign command suppress review of later commands that happen to + # match the same broad detector category. logger.debug("Smart approval: auto-approved '%s' (%s)", command[:60], combined_desc_for_llm) return {"approved": True, "message": None, "smart_approved": True, "description": combined_desc_for_llm} - elif verdict == "deny": - combined_desc_for_llm = "; ".join(desc for _, desc, _ in warnings) + elif verdict == "deny" and not (is_cli or is_gateway or is_ask): return { "approved": False, "message": f"BLOCKED by smart approval: {combined_desc_for_llm}. " "The command was assessed as genuinely dangerous. Do NOT retry.", "smart_denied": True, } - # verdict == "escalate" → fall through to manual prompt + elif verdict == "deny": + smart_denied_for_owner = True + # An interactive owner may override DENY for this operation only. + # ESCALATE follows the normal, potentially persistent manual behavior. # --- Phase 3: Approval --- @@ -2798,10 +2907,12 @@ def check_all_command_guards(command: str, env_type: str, "pattern_key": primary_key, "pattern_keys": all_keys, "description": redact_sensitive_text(combined_desc), - # Mirror the CLI's allow_permanent gate: a tirith warning downgrades - # "always" to session scope below, so the UI must not offer it. - "allow_permanent": not has_tirith, + # Smart DENY overrides are one-operation decisions, so the UI + # must not offer a permanent scope. + "allow_permanent": not has_tirith and not smart_denied_for_owner, } + if smart_denied_for_owner: + approval_data["smart_denied"] = True decision = _await_gateway_decision( session_key, notify_cb, approval_data, surface="gateway" ) @@ -2854,16 +2965,17 @@ def check_all_command_guards(command: str, env_type: str, "deny_reason": deny_reason, } - # User approved — persist based on scope (same logic as CLI) - for key, _, is_tirith in warnings: - if choice == "session" or (choice == "always" and is_tirith): - approve_session(session_key, key) - elif choice == "always": - approve_session(session_key, key) - approve_permanent(key) - save_permanent_allowlist(_permanent_approved) - # choice == "once": no persistence — command allowed this - # single time only, matching the CLI's behavior. + # A smart-DENY owner override is always one operation, even if an + # older client returns "session" or "always". Manual and ESCALATE + # choices retain their existing persistence semantics. + if not smart_denied_for_owner: + for key, _, is_tirith in warnings: + if choice == "session" or (choice == "always" and is_tirith): + approve_session(session_key, key) + elif choice == "always": + approve_session(session_key, key) + approve_permanent(key) + save_permanent_allowlist(_permanent_approved) return {"approved": True, "message": None, "user_approved": True, "description": combined_desc} @@ -2875,13 +2987,16 @@ def check_all_command_guards(command: str, env_type: str, from agent.redact import redact_sensitive_text _disp_command = redact_sensitive_text(command) _disp_combined_desc = redact_sensitive_text(combined_desc) - submit_pending(session_key, { + pending_data = { "command": _disp_command, "pattern_key": primary_key, "pattern_keys": all_keys, "description": _disp_combined_desc, - }) - return { + } + if smart_denied_for_owner: + pending_data.update(smart_denied=True, allow_permanent=False) + submit_pending(session_key, pending_data) + result = { "approved": False, "pattern_key": primary_key, "status": "pending_approval", @@ -2892,6 +3007,9 @@ def check_all_command_guards(command: str, env_type: str, f"⚠️ {_disp_combined_desc}. Asking the user for approval.\n\n**Command:**\n```\n{_disp_command}\n```" ), } + if smart_denied_for_owner: + result.update(smart_denied=True, allow_permanent=False) + return result # CLI interactive: single combined prompt # Hide [a]lways when any tirith warning is present @@ -2904,9 +3022,13 @@ def check_all_command_guards(command: str, env_type: str, session_key=session_key, surface="cli", ) - choice = prompt_dangerous_approval(command, combined_desc, - allow_permanent=not has_tirith, - approval_callback=approval_callback) + choice = prompt_dangerous_approval( + command, + combined_desc, + allow_permanent=not has_tirith and not smart_denied_for_owner, + smart_denied=smart_denied_for_owner, + approval_callback=approval_callback, + ) _fire_approval_hook( "post_approval_response", command=command, @@ -2935,16 +3057,18 @@ def check_all_command_guards(command: str, env_type: str, "user_consent": False, } - # Persist approval for each warning individually - for key, _, is_tirith in warnings: - if choice == "session" or (choice == "always" and is_tirith): - # tirith: session only (no permanent broad allowlisting) - approve_session(session_key, key) - elif choice == "always": - # dangerous patterns: permanent allowed - approve_session(session_key, key) - approve_permanent(key) - save_permanent_allowlist(_permanent_approved) + # Smart-DENY owner overrides are one-operation scoped. Preserve existing + # persistence for manual mode and smart ESCALATE. + if not smart_denied_for_owner: + for key, _, is_tirith in warnings: + if choice == "session" or (choice == "always" and is_tirith): + # tirith: session only (no permanent broad allowlisting) + approve_session(session_key, key) + elif choice == "always": + # dangerous patterns: permanent allowed + approve_session(session_key, key) + approve_permanent(key) + save_permanent_allowlist(_permanent_approved) return {"approved": True, "message": None, "user_approved": True, "description": combined_desc} @@ -3026,17 +3150,6 @@ def check_execute_code_guard(code: str, env_type: str, # paths don't pay to copy a potentially-large script into this string. command = f"execute_code <<'PY'\n{code}\nPY" - # Redacted copies for user-visible rendering only. An execute_code script - # can embed credentials (e.g. api_key = "sk-..."), and the gateway renders - # this payload directly to Discord/Slack — those messages are - # screenshottable. The raw `command`/`code` are still what get assessed by - # smart approval and executed; redaction is display-only. Approval - # persistence keys off pattern_key, so the allowlist is unaffected. - from agent.redact import redact_sensitive_text - display_command = redact_sensitive_text(command) - display_code = redact_sensitive_text(code) - display_description = redact_sensitive_text(description) - # Check session/permanent approval — same gate as check_all_command_guards. # Without this, "Approve session" / "Always" choices are stored but never # consulted, so every execute_code call re-prompts the user (#39275). @@ -3046,14 +3159,23 @@ def check_execute_code_guard(code: str, env_type: str, # Smart mode: ask the aux LLM about the whole script. An APPROVE here only # suppresses the redundant whole-script prompt; the per-call terminal() # guards (restored by context propagation) still run independently. + smart_denied_for_owner = False if approval_mode == "smart": + observer_payload = _prepare_smart_approval_observer( + command=command, + description=description, + pattern_key=pattern_key, + pattern_keys=[pattern_key], + session_key=session_key, + ) verdict = _smart_approve(command, description) + _observe_smart_approval_verdict(observer_payload, verdict) if verdict == "approve": logger.debug("Smart approval: auto-approved execute_code for session %s", session_key) return {"approved": True, "message": None, "smart_approved": True, "description": description} - if verdict == "deny": + if verdict == "deny" and not (is_gateway or is_ask): return { "approved": False, "message": ("BLOCKED by smart approval: execute_code script " @@ -3065,7 +3187,21 @@ def check_execute_code_guard(code: str, env_type: str, "outcome": "denied", "user_consent": False, } - # verdict == "escalate" → fall through to manual approval + if verdict == "deny": + smart_denied_for_owner = True + # Interactive DENY falls through to one-operation human approval; + # ESCALATE retains the normal manual approval behavior. + + # Redacted copies for user-visible rendering only. An execute_code script + # can embed credentials (e.g. api_key = "sk-..."), and the gateway renders + # this payload directly to Discord/Slack — those messages are + # screenshottable. The raw `command`/`code` are still what get assessed by + # smart approval and executed; redaction is display-only. Approval + # persistence keys off pattern_key, so the allowlist is unaffected. + from agent.redact import redact_sensitive_text + display_command = redact_sensitive_text(command) + display_code = redact_sensitive_text(code) + display_description = redact_sensitive_text(description) notify_cb = None with _lock: @@ -3074,13 +3210,16 @@ def check_execute_code_guard(code: str, env_type: str, if notify_cb is None: # No gateway callback registered (e.g. ask-mode without a notifier): # surface a pending approval for backward compatibility. - submit_pending(session_key, { + pending_data = { "command": display_command, "pattern_key": pattern_key, "pattern_keys": [pattern_key], "description": display_description, - }) - return { + } + if smart_denied_for_owner: + pending_data.update(smart_denied=True, allow_permanent=False) + submit_pending(session_key, pending_data) + result = { "approved": False, "pattern_key": pattern_key, "status": "pending_approval", @@ -3092,13 +3231,19 @@ def check_execute_code_guard(code: str, env_type: str, f"**Code:**\n```python\n{display_code}\n```" ), } + if smart_denied_for_owner: + result.update(smart_denied=True, allow_permanent=False) + return result approval_data = { "command": display_command, "pattern_key": pattern_key, "pattern_keys": [pattern_key], "description": display_description, + "allow_permanent": not smart_denied_for_owner, } + if smart_denied_for_owner: + approval_data["smart_denied"] = True decision = _await_gateway_decision( session_key, notify_cb, approval_data, surface="gateway" ) @@ -3138,13 +3283,16 @@ def check_execute_code_guard(code: str, env_type: str, "deny_reason": deny_reason, } - # Approved — persist based on scope (same logic as check_all_command_guards). - if choice == "session": - approve_session(session_key, pattern_key) - elif choice == "always": - approve_session(session_key, pattern_key) - approve_permanent(pattern_key) - save_permanent_allowlist(_permanent_approved) + # Never persist a smart-DENY override under the coarse execute_code key; + # doing so would approve unrelated future scripts. Manual and ESCALATE + # decisions preserve their existing session/permanent behavior. + if not smart_denied_for_owner: + if choice == "session": + approve_session(session_key, pattern_key) + elif choice == "always": + approve_session(session_key, pattern_key) + approve_permanent(pattern_key) + save_permanent_allowlist(_permanent_approved) # choice == "once": no persistence — approval lasts this single call only. return {"approved": True, "message": None, diff --git a/tools/async_delegation.py b/tools/async_delegation.py index 9b4b0e9646ac..c743decf644e 100644 --- a/tools/async_delegation.py +++ b/tools/async_delegation.py @@ -36,13 +36,16 @@ logic stays in one place. from __future__ import annotations +import json import logging +import sqlite3 import threading import time import uuid from concurrent.futures import ThreadPoolExecutor from typing import Any, Callable, Dict, List, Optional +from hermes_constants import get_hermes_home from tools.daemon_pool import DaemonThreadPoolExecutor from tools.thread_context import propagate_context_to_thread @@ -72,6 +75,317 @@ _records: Dict[str, Dict[str, Any]] = {} _DEFAULT_MAX_ASYNC_CHILDREN = 3 # How many completed records to retain for status queries before pruning. _MAX_RETAINED_COMPLETED = 50 +_DURABLE_RETENTION_SECONDS = 7 * 24 * 60 * 60 +_MAX_DURABLE_PENDING = 1000 +_DB_LOCK = threading.Lock() + + +def _db_path(): + return get_hermes_home() / "state.db" + + +def _connect() -> sqlite3.Connection: + path = _db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path, timeout=10) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute( + """CREATE TABLE IF NOT EXISTS async_delegations ( + delegation_id TEXT PRIMARY KEY, + origin_session TEXT NOT NULL, + origin_ui_session_id TEXT NOT NULL DEFAULT '', + parent_session_id TEXT, + state TEXT NOT NULL, + dispatched_at REAL NOT NULL, + completed_at REAL, + updated_at REAL NOT NULL, + event_json TEXT, + result_json TEXT, + delivery_state TEXT NOT NULL DEFAULT 'pending', + delivery_attempts INTEGER NOT NULL DEFAULT 0, + delivered_at REAL, + owner_pid INTEGER, + owner_started_at INTEGER, + task_json TEXT, + delivery_claim TEXT, + delivery_claimed_at REAL + )""" + ) + columns = {row[1] for row in conn.execute("PRAGMA table_info(async_delegations)")} + for name, sql_type in ( + ("owner_pid", "INTEGER"), + ("owner_started_at", "INTEGER"), + ("task_json", "TEXT"), + ("delivery_claim", "TEXT"), + ("delivery_claimed_at", "REAL"), + ): + if name not in columns: + conn.execute(f"ALTER TABLE async_delegations ADD COLUMN {name} {sql_type}") + return conn + + +def _persist_dispatch(record: Dict[str, Any]) -> None: + now = time.time() + try: + from gateway.status import get_process_start_time + owner_started_at = get_process_start_time(__import__("os").getpid()) + except Exception: + owner_started_at = None + task_payload = { + key: record.get(key) + for key in ("goal", "goals", "context", "toolsets", "role", "model", "is_batch") + if key in record + } + with _DB_LOCK, _connect() as conn: + conn.execute( + """INSERT OR REPLACE INTO async_delegations + (delegation_id, origin_session, origin_ui_session_id, + parent_session_id, state, dispatched_at, updated_at, + delivery_state, delivery_attempts, owner_pid, + owner_started_at, task_json) + VALUES (?, ?, ?, ?, 'running', ?, ?, 'pending', 0, ?, ?, ?)""", + (record["delegation_id"], record.get("session_key", ""), + record.get("origin_ui_session_id", ""), record.get("parent_session_id"), + record["dispatched_at"], now, __import__("os").getpid(), + owner_started_at, json.dumps(task_payload)), + ) + _prune_durable_records() + + +def _delete_durable_delegation(delegation_id: str) -> None: + with _DB_LOCK, _connect() as conn: + conn.execute("DELETE FROM async_delegations WHERE delegation_id=?", (delegation_id,)) + + +def _prune_durable_records() -> None: + """Bound terminal history, preferring delivered records for deletion.""" + now = time.time() + cutoff = now - _DURABLE_RETENTION_SECONDS + with _DB_LOCK, _connect() as conn: + conn.execute( + "DELETE FROM async_delegations WHERE delivery_state='delivered' AND updated_at < ?", + (cutoff,), + ) + terminal_count = conn.execute( + "SELECT COUNT(*) FROM async_delegations WHERE state NOT IN ('running','finalizing')" + ).fetchone()[0] + excess = max(0, terminal_count - _MAX_RETAINED_COMPLETED) + if excess: + conn.execute( + """DELETE FROM async_delegations WHERE delegation_id IN ( + SELECT delegation_id FROM async_delegations + WHERE state NOT IN ('running','finalizing') + ORDER BY CASE delivery_state WHEN 'delivered' THEN 0 ELSE 1 END, + updated_at ASC LIMIT ? + )""", + (excess,), + ) + pending_count = conn.execute( + """SELECT COUNT(*) FROM async_delegations + WHERE state NOT IN ('running','finalizing') AND delivery_state='pending'""" + ).fetchone()[0] + overflow = max(0, pending_count - _MAX_DURABLE_PENDING) + if overflow: + conn.execute( + """DELETE FROM async_delegations WHERE delegation_id IN ( + SELECT delegation_id FROM async_delegations + WHERE state NOT IN ('running','finalizing') AND delivery_state='pending' + ORDER BY updated_at ASC LIMIT ? + )""", + (overflow,), + ) + + +def _persist_completion(event: Dict[str, Any], result: Dict[str, Any]) -> None: + now = time.time() + with _DB_LOCK, _connect() as conn: + conn.execute( + """UPDATE async_delegations SET state=?, completed_at=?, updated_at=?, + event_json=?, result_json=?, delivery_state='pending' + WHERE delegation_id=?""", + (event.get("status", "completed"), event.get("completed_at", now), now, + json.dumps(event), json.dumps(result), event["delegation_id"]), + ) + + +def _note_delivery_attempt(delegation_id: str) -> None: + with _DB_LOCK, _connect() as conn: + conn.execute( + "UPDATE async_delegations SET delivery_attempts=delivery_attempts+1, updated_at=? WHERE delegation_id=?", + (time.time(), delegation_id), + ) + + +def recover_abandoned_delegations() -> int: + """Classify records whose owning process disappeared as outcome unknown.""" + try: + from gateway.status import _pid_exists, get_process_start_time + except Exception: + return 0 + now = time.time() + recovered = 0 + with _DB_LOCK, _connect() as conn: + rows = conn.execute( + """SELECT delegation_id, origin_session, origin_ui_session_id, + parent_session_id, dispatched_at, owner_pid, + owner_started_at, task_json + FROM async_delegations WHERE state IN ('running','finalizing')""" + ).fetchall() + for row in rows: + delegation_id, session_key, origin_ui, parent_id, dispatched_at, pid, started, task_json = row + live = False + if pid: + live = _pid_exists(int(pid)) + if live and started is not None: + live = get_process_start_time(int(pid)) == int(started) + if live: + continue + task = json.loads(task_json or "{}") + event = { + "type": "async_delegation", "delegation_id": delegation_id, + "session_key": session_key, "origin_ui_session_id": origin_ui, + "parent_session_id": parent_id, "goal": task.get("goal", ""), + "goals": task.get("goals"), "context": task.get("context"), + "toolsets": task.get("toolsets"), "role": task.get("role"), + "model": task.get("model"), "is_batch": bool(task.get("is_batch")), + "status": "unknown", "summary": None, + "error": "Delegation owner exited before recording a terminal result; outcome unknown.", + "dispatched_at": dispatched_at, "completed_at": now, + } + result = {"status": "unknown", "summary": None, "error": event["error"]} + conn.execute( + """UPDATE async_delegations SET state='unknown', completed_at=?, + updated_at=?, event_json=?, result_json=?, delivery_state='pending' + WHERE delegation_id=?""", + (now, now, json.dumps(event), json.dumps(result), delegation_id), + ) + recovered += 1 + return recovered + + +def restore_undelivered_completions(target_queue) -> int: + """Enqueue durable pending completions as fresh turns after process start. + + Every restored event is stamped ``restored=True`` (in-memory only — the + stamp is added after the durable payload is deserialized and is never + persisted). Restored events originate from a *previous* process, so no + consumer in THIS process implicitly owns them: drain paths that run + without an ownership filter (the legacy single-session behavior) must + leave them queued for a consumer that can positively prove ownership, + otherwise a brand-new session adopts a dead session's delegation + results seconds after boot (#64484). + """ + recover_abandoned_delegations() + with _DB_LOCK, _connect() as conn: + rows = conn.execute( + """SELECT delegation_id, event_json FROM async_delegations + WHERE state != 'running' AND delivery_state='pending' AND event_json IS NOT NULL + ORDER BY completed_at, delegation_id""" + ).fetchall() + for _delegation_id, payload in rows: + evt = json.loads(payload) + if isinstance(evt, dict): + evt["restored"] = True + target_queue.put(evt) + return len(rows) + + +def mark_completion_delivered(delegation_id: str) -> bool: + """Atomically acknowledge successful injection of a durable completion.""" + now = time.time() + with _DB_LOCK, _connect() as conn: + cur = conn.execute( + """UPDATE async_delegations SET delivery_state='delivered', delivered_at=?, updated_at=? + WHERE delegation_id=? AND delivery_state!='delivered'""", + (now, now, delegation_id), + ) + return cur.rowcount == 1 + + +def claim_completion_delivery(delegation_id: str, claim_id: str) -> bool: + """Claim one pending completion across competing consumers/processes.""" + now = time.time() + with _DB_LOCK, _connect() as conn: + row = conn.execute( + "SELECT delivery_state FROM async_delegations WHERE delegation_id=?", + (delegation_id,), + ).fetchone() + if row is None: + return True # legacy event created before durable dispatch + cur = conn.execute( + """UPDATE async_delegations SET delivery_claim=?, delivery_claimed_at=?, + delivery_attempts=delivery_attempts+1, updated_at=? + WHERE delegation_id=? AND delivery_state='pending' + AND (delivery_claim IS NULL OR delivery_claimed_at < ?)""", + (claim_id, now, now, delegation_id, now - 300), + ) + return cur.rowcount == 1 + + +def claim_event_delivery(evt: Dict[str, Any], consumer: str) -> Optional[str]: + """Claim a durable delegation event; non-durable events need no token.""" + if evt.get("type") != "async_delegation": + return "" + delegation_id = str(evt.get("delegation_id") or "") + if not delegation_id: + return "" + claim_id = f"{consumer}:{__import__('os').getpid()}:{uuid.uuid4().hex}" + return claim_id if claim_completion_delivery(delegation_id, claim_id) else None + + +def release_completion_delivery(delegation_id: str, claim_id: str) -> bool: + """Release a failed delivery claim so another consumer may retry.""" + with _DB_LOCK, _connect() as conn: + cur = conn.execute( + """UPDATE async_delegations SET delivery_claim=NULL, + delivery_claimed_at=NULL, updated_at=? + WHERE delegation_id=? AND delivery_state='pending' + AND delivery_claim=?""", + (time.time(), delegation_id, claim_id), + ) + return cur.rowcount == 1 + + +def complete_completion_delivery(delegation_id: str, claim_id: str) -> bool: + """Acknowledge acceptance for the consumer holding this claim.""" + now = time.time() + with _DB_LOCK, _connect() as conn: + cur = conn.execute( + """UPDATE async_delegations SET delivery_state='delivered', + delivered_at=?, updated_at=?, delivery_claim=NULL, + delivery_claimed_at=NULL + WHERE delegation_id=? AND delivery_state='pending' + AND delivery_claim=?""", + (now, now, delegation_id, claim_id), + ) + return cur.rowcount == 1 + + +def complete_event_delivery(evt: Dict[str, Any], claim_id: str) -> None: + if claim_id and evt.get("type") == "async_delegation": + complete_completion_delivery(str(evt.get("delegation_id") or ""), claim_id) + + +def release_event_delivery(evt: Dict[str, Any], claim_id: str) -> None: + if claim_id and evt.get("type") == "async_delegation": + release_completion_delivery(str(evt.get("delegation_id") or ""), claim_id) + + +def get_durable_delegation(delegation_id: str) -> Optional[Dict[str, Any]]: + with _DB_LOCK, _connect() as conn: + row = conn.execute( + """SELECT origin_session, state, dispatched_at, completed_at, + result_json, delivery_state, delivery_attempts + FROM async_delegations WHERE delegation_id=?""", (delegation_id,), + ).fetchone() + if row is None: + return None + return { + "delegation_id": delegation_id, "origin_session": row[0], "state": row[1], + "dispatched_at": row[2], "completed_at": row[3], + "result": json.loads(row[4]) if row[4] else None, + "delivery_state": row[5], "delivery_attempts": row[6], + } def _get_executor(max_workers: int) -> ThreadPoolExecutor: @@ -96,7 +410,7 @@ def _get_executor(max_workers: int) -> ThreadPoolExecutor: def active_count() -> int: """Number of async delegations currently running.""" with _records_lock: - return sum(1 for r in _records.values() if r.get("status") == "running") + return sum(1 for r in _records.values() if r.get("status") in {"running", "finalizing"}) def _new_delegation_id() -> str: @@ -206,6 +520,7 @@ def dispatch_async_delegation( } _records[delegation_id] = record + _persist_dispatch(record) executor = _get_executor(max_async_children) def _worker() -> None: @@ -234,6 +549,7 @@ def dispatch_async_delegation( except Exception as exc: # pragma: no cover — pool submit failure is rare with _records_lock: _records.pop(delegation_id, None) + _delete_durable_delegation(delegation_id) return { "status": "rejected", "error": f"Failed to schedule async delegation: {exc}", @@ -252,14 +568,20 @@ def _finalize(delegation_id: str, result: Dict[str, Any], status: str) -> None: record = _records.get(delegation_id) if record is None: return - record["status"] = status + # Stay active until durable persistence and queue publication finish; + # otherwise process shutdown can kill this daemon worker in the narrow + # gap after status flips but before SQLite is committed. + record["status"] = "finalizing" record["completed_at"] = time.time() record["interrupt_fn"] = None # drop the closure; child is done - # Snapshot fields needed for the event while holding the lock. event_record = dict(record) - _prune_completed_locked() _push_completion_event(event_record, result, status) + with _records_lock: + record = _records.get(delegation_id) + if record is not None: + record["status"] = status + _prune_completed_locked() def _push_completion_event( @@ -309,6 +631,7 @@ def _push_completion_event( "completed_at": completed_at, "exit_reason": result.get("exit_reason"), } + _persist_completion(evt, result) try: process_registry.completion_queue.put(evt) except Exception as exc: # pragma: no cover @@ -393,6 +716,7 @@ def dispatch_async_delegation_batch( } _records[delegation_id] = record + _persist_dispatch(record) executor = _get_executor(max_async_children) def _worker() -> None: @@ -426,6 +750,7 @@ def dispatch_async_delegation_batch( except Exception as exc: # pragma: no cover with _records_lock: _records.pop(delegation_id, None) + _delete_durable_delegation(delegation_id) return { "status": "rejected", "error": f"Failed to schedule async delegation batch: {exc}", @@ -446,11 +771,10 @@ def _finalize_batch( record = _records.get(delegation_id) if record is None: return - record["status"] = status + record["status"] = "finalizing" record["completed_at"] = time.time() record["interrupt_fn"] = None event_record = dict(record) - _prune_completed_locked() try: from tools.process_registry import process_registry @@ -486,6 +810,7 @@ def _finalize_batch( "dispatched_at": dispatched_at, "completed_at": completed_at, } + _persist_completion(evt, combined) try: process_registry.completion_queue.put(evt) except Exception as exc: # pragma: no cover @@ -494,6 +819,12 @@ def _finalize_batch( "result lost: %s", delegation_id, exc, ) + finally: + with _records_lock: + record = _records.get(delegation_id) + if record is not None: + record["status"] = status + _prune_completed_locked() def list_async_delegations() -> List[Dict[str, Any]]: diff --git a/tools/computer_use/backend.py b/tools/computer_use/backend.py index 0537f47b246b..ba109cf2e42e 100644 --- a/tools/computer_use/backend.py +++ b/tools/computer_use/backend.py @@ -99,7 +99,13 @@ class ComputerUseBackend(ABC): # ── Capture ───────────────────────────────────────────────────── @abstractmethod - def capture(self, mode: str = "som", app: Optional[str] = None) -> CaptureResult: ... + def capture( + self, + mode: str = "som", + app: Optional[str] = None, + pid: Optional[int] = None, + window_id: Optional[int] = None, + ) -> CaptureResult: ... # ── Pointer actions ───────────────────────────────────────────── @abstractmethod @@ -151,6 +157,14 @@ class ComputerUseBackend(ABC): def list_apps(self) -> List[Dict[str, Any]]: """Return running apps with bundle IDs, PIDs, window counts.""" + def list_windows(self) -> List[Dict[str, Any]]: + """Return visible native windows with PID and window identifiers. + + Optional compatibility hook: backends that predate window discovery + remain instantiable and simply report no windows. + """ + return [] + @abstractmethod def focus_app(self, app: str, raise_window: bool = False) -> ActionResult: """Route input to `app` (by name or bundle ID). Default: focus without raise.""" diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 32dae8b7b861..1481d09fbb36 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -960,7 +960,12 @@ class _CuaDriverSession: images: List[str] = [] data: Any = None structured: Optional[Dict] = parsed if isinstance(parsed, dict) else None + is_error = False if isinstance(parsed, dict): + # Current cua-driver CLI responses may report logical failures + # in-band even when the subprocess itself exits successfully. + # Preserve that bit so stateful callers can fail closed. + is_error = parsed.get("isError") is True or parsed.get("is_error") is True shot = parsed.get("screenshot_png_b64") if not shot: # Screenshot was routed to a file (ours or the daemon's choice). @@ -978,7 +983,12 @@ class _CuaDriverSession: ec = parsed.get("element_count") summary = f"{ec} elements" if ec is not None else "" data = f"{summary}\n{tree}" if summary else tree - return {"data": data, "images": images, "structuredContent": structured, "isError": False} + return { + "data": data, + "images": images, + "structuredContent": structured, + "isError": is_error, + } finally: if shot_file and os.path.exists(shot_file): try: @@ -1042,7 +1052,9 @@ def _extract_tool_result(mcp_result: Any) -> Dict[str, Any]: data: Any = None images: List[str] = [] image_mime_types: List[str] = [] - is_error = bool(getattr(mcp_result, "isError", False)) + # Use identity, not truthiness: unittest mocks and proxy objects commonly + # synthesize truthy attributes that were never present in the real result. + is_error = getattr(mcp_result, "isError", False) is True structured: Optional[Dict] = getattr(mcp_result, "structuredContent", None) or None text_chunks: List[str] = [] for part in getattr(mcp_result, "content", []) or []: @@ -1109,6 +1121,17 @@ def _image_from_tool_result(out: Dict[str, Any]) -> tuple[Optional[str], Optiona return None, None +def _positive_int(value: Any) -> Optional[int]: + """Return a positive integer, rejecting booleans and malformed values.""" + if isinstance(value, bool) or not isinstance(value, (int, str)): + return None + try: + parsed = int(value) + except ValueError: + return None + return parsed if parsed > 0 else None + + def _ingest_windows(raw_windows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Normalise cua-driver ``list_windows`` entries, dropping unusable ones. @@ -1123,23 +1146,28 @@ def _ingest_windows(raw_windows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: (``int(w["pid"])``) let one such window abort enumeration of the real, targetable windows. We skip the unusable entries instead so capture() and focus_app() still find the windows that matter. + + ``z_index`` follows CUA Driver semantics: higher = closer to front. + Wayland may return ``z_index: null`` (undefined stacking order); we + treat null as the lowest priority so real windows still sort above + desktop/root windows, and the backmost never ends up selected as the + capture target. """ windows: List[Dict[str, Any]] = [] for w in raw_windows: - pid, window_id = w.get("pid"), w.get("window_id") - if pid is None or window_id is None: - continue - try: - pid_int, window_id_int = int(pid), int(window_id) - except (TypeError, ValueError): + pid_int = _positive_int(w.get("pid")) + window_id_int = _positive_int(w.get("window_id")) + if pid_int is None or window_id_int is None: continue + z_raw = w.get("z_index") + z_index = z_raw if isinstance(z_raw, (int, float)) and not isinstance(z_raw, bool) else 0 windows.append({ "app_name": w.get("app_name", ""), "pid": pid_int, "window_id": window_id_int, "off_screen": not w.get("is_on_screen", True), "title": w.get("title", ""), - "z_index": w.get("z_index", 0), + "z_index": z_index, }) return windows @@ -1158,6 +1186,9 @@ class CuaDriverBackend(ComputerUseBackend): self._active_pid: Optional[int] = None self._active_window_id: Optional[int] = None self._last_app: Optional[str] = None # last app name targeted via capture/focus_app + # Exact identity for capture_after. App names may be generic on Linux + # (for example, multiple unrelated Qt windows can say Qt6Application). + self._last_target: Optional[Dict[str, Optional[int]]] = None # Surface 6 of NousResearch/hermes-agent#47072: per-snapshot # `element_index -> element_token` map populated on capture(). # Action tools (click/scroll/set_value/...) attach the matching @@ -1243,9 +1274,174 @@ class CuaDriverBackend(ComputerUseBackend): return False return cua_driver_binary_available() + def _clear_active_target(self) -> None: + """Forget a capture/focus target so a failed lookup cannot misroute input.""" + self._active_pid = None + self._active_window_id = None + self._last_app = None + self._last_target = None + self._snapshot_tokens = {} + + def _failed_capture(self, mode: str, message: str = "") -> CaptureResult: + """Return an empty capture after disarming any prior target context.""" + self._clear_active_target() + return CaptureResult( + mode=mode, + width=0, + height=0, + png_b64=None, + elements=[], + app="", + window_title=message, + png_bytes_len=0, + ) + + def _call_capture_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]: + """Call a capture-stage tool and disarm state on transport or logical failure.""" + try: + out = self._session.call_tool(name, args) + except Exception: + self._clear_active_target() + raise + if out.get("isError") is True: + message = out.get("data") + self._clear_active_target() + raise RuntimeError( + f"cua-driver {name} failed" + + (f": {message}" if isinstance(message, str) and message else "") + ) + return out + + def _load_windows(self) -> List[Dict[str, Any]]: + """Load normalized visible windows, with the shared CLI recovery path. + + Windows are sorted by ``z_index`` **descending**: CUA Driver + defines higher values as closer to the front, so the frontmost + window ends up at index 0 — which is what ``capture()`` and + ``focus_app()`` pick as the default target. ``_ingest_windows`` + already normalised null ``z_index`` (Wayland) to 0, so those + windows sort to the back. + """ + out = self._call_capture_tool( + "list_windows", + {"on_screen_only": True, "session": self._session_id}, + ) + raw_windows = (out.get("structuredContent") or {}).get("windows") or [] + windows = _ingest_windows(raw_windows) + windows.sort(key=lambda w: w["z_index"], reverse=True) + if windows: + return windows + + logger.warning( + "cua-driver list_windows returned no windows over MCP; " + "re-fetching via CLI transport", + ) + try: + cli_out = self._session._call_tool_via_cli( + "list_windows", + {"on_screen_only": True, "session": self._session_id}, + 20.0, + ) + except Exception as exc: + logger.error("cua-driver CLI re-fetch for list_windows failed: %s", exc) + return [] + if cli_out.get("isError") is True: + logger.error("cua-driver CLI re-fetch for list_windows returned an error") + self._clear_active_target() + return [] + raw_windows = (cli_out.get("structuredContent") or {}).get("windows") or [] + windows = _ingest_windows(raw_windows) + windows.sort(key=lambda w: w["z_index"], reverse=True) + return windows + + def _match_windows_for_app( + self, windows: List[Dict[str, Any]], app: str + ) -> List[Dict[str, Any]]: + """Resolve ``app=`` through exact names before convenience substrings. + + Linux ``list_windows`` can omit an app name while ``list_apps`` retains + name/bundle-ID metadata. Exact direct names and exact metadata aliases + must win over substring matches: querying ``Code`` must not silently + select ``Visual Studio Code`` merely because it is frontmost. + """ + app_lower = app.strip().lower() + if not app_lower: + return [] + + direct_exact = [ + w for w in windows + if app_lower == str(w.get("app_name", "")).strip().lower() + ] + if direct_exact: + return direct_exact + + try: + running_apps = self.list_apps() + except Exception as exc: + # A title can still be the only usable identity on X11 when app + # enumeration is unavailable, so retain the constrained title + # fallback below instead of treating this as a hard no-match. + logger.debug("computer_use list_apps fallback failed for %r: %s", app, exc) + running_apps = [] + + exact_pids: set[int] = set() + partial_pids: set[int] = set() + for raw_app in running_apps: + if not isinstance(raw_app, dict) or raw_app.get("running") is False: + continue + raw_pid = raw_app.get("pid") + if isinstance(raw_pid, bool) or not isinstance(raw_pid, (int, str)): + continue + try: + pid = int(raw_pid) + except ValueError: + continue + if pid <= 0: + continue + + aliases = { + value.strip().lower() + for key in ("bundle_id", "bundleId", "name", "app_name", "display_name") + if isinstance((value := raw_app.get(key)), str) and value.strip() + } + if app_lower in aliases: + exact_pids.add(pid) + elif any(app_lower in alias for alias in aliases): + partial_pids.add(pid) + + metadata_exact = [w for w in windows if w.get("pid") in exact_pids] + if metadata_exact: + return metadata_exact + + direct_partial = [ + w for w in windows + if app_lower in str(w.get("app_name", "")).lower() + ] + if direct_partial: + return direct_partial + + metadata_partial = [w for w in windows if w.get("pid") in partial_pids] + if metadata_partial: + return metadata_partial + + # Some X11 backends expose a title but no app name. Restrict this final + # fallback to nameless rows so a localized app name is not overridden + # merely because its title happens to be in the caller's language. + return [ + w for w in windows + if not str(w.get("app_name", "")).strip() + and app_lower in str(w.get("title", "")).lower() + ] + # ── Capture ──────────────────────────────────────────────────── - def capture(self, mode: str = "som", app: Optional[str] = None) -> CaptureResult: - """Capture the frontmost on-screen window (optionally filtered by app name). + def capture( + self, + mode: str = "som", + app: Optional[str] = None, + pid: Optional[int] = None, + window_id: Optional[int] = None, + ) -> CaptureResult: + """Capture the frontmost on-screen window or an exact known target. Maps hermes `capture(mode, app)` → cua-driver `list_windows` + `get_window_state` (ax/som) or `screenshot` (vision). @@ -1258,41 +1454,35 @@ class CuaDriverBackend(ComputerUseBackend): # PR's effective minimum (trycua/cua#1961 + #1908) is well past # that, so the fallback is gone — the wrapper now treats the # structured shape as the only contract. - lw_out = self._session.call_tool( - "list_windows", - {"on_screen_only": True, "session": self._session_id}, - ) - - def _windows_from(out: Dict[str, Any]) -> List[Dict[str, Any]]: - raw_ = (out.get("structuredContent") or {}).get("windows") or [] - wins_ = _ingest_windows(raw_) - # Sort by z_index descending (lowest z_index = frontmost on macOS). - wins_.sort(key=lambda w: w["z_index"]) - return wins_ - - windows = _windows_from(lw_out) - - # If the MCP bridge returned an empty/degenerate window list (flaky - # session), re-fetch over the CLI transport before giving up — otherwise - # the caller sees a silent 0x0 capture even though windows exist. - if not windows: - logger.warning( - "cua-driver list_windows returned no windows over MCP; " - "re-fetching via CLI transport", - ) - try: - cli_lw = self._session._call_tool_via_cli( - "list_windows", - {"on_screen_only": True, "session": self._session_id}, - 20.0, + # An exact pid/window pair is both the stable capture_after target and + # the escape hatch when app/window discovery is unavailable on X11. + if pid is not None or window_id is not None: + if pid is None or window_id is None: + return self._failed_capture( + mode, "", ) - windows = _windows_from(cli_lw) - except Exception as cli_exc: - logger.error("cua-driver CLI re-fetch for list_windows failed: %s", cli_exc) - - if not windows: - return CaptureResult(mode=mode, width=0, height=0, png_b64=None, - elements=[], app="", window_title="", png_bytes_len=0) + target_pid = _positive_int(pid) + target_window_id = _positive_int(window_id) + if target_pid is None or target_window_id is None: + return self._failed_capture( + mode, "", + ) + windows = [{ + "app_name": app or "", + "pid": target_pid, + "window_id": target_window_id, + "off_screen": False, + "title": "", + "z_index": 0, + }] + else: + try: + windows = self._load_windows() + except Exception: + self._clear_active_target() + raise + if not windows: + return self._failed_capture(mode) # Filter by app name (case-insensitive substring) if requested. # When the filter matches nothing, surface that explicitly instead of @@ -1300,7 +1490,7 @@ class CuaDriverBackend(ComputerUseBackend): # returned by list_windows is the localized name (e.g. "計算機"), so # `app="Calculator"` legitimately matches no windows on a non-English # system and the caller needs to retry with the localized name. - if app and app.strip().lower() in _SCREEN_CAPTURE_SENTINELS: + if pid is None and window_id is None and app and app.strip().lower() in _SCREEN_CAPTURE_SENTINELS: # Whole-screen / desktop request. cua-driver has no virtual-desktop # capture tool, so resolve to the OS shell/desktop window (the # desktop backdrop or the taskbar/menu-bar), which list_windows @@ -1313,10 +1503,9 @@ class CuaDriverBackend(ComputerUseBackend): desktop = [w for w in windows if _is_desktop_window(w)] if not desktop: - return CaptureResult( - mode=mode, width=0, height=0, png_b64=None, - elements=[], app="", - window_title=( + return self._failed_capture( + mode, + ( f"" ), - png_bytes_len=0, ) # Prefer the desktop backdrop (Progman/WorkerW/Finder) over the # taskbar when both are present, so a bare "screen" capture shows @@ -1336,20 +1524,18 @@ class CuaDriverBackend(ComputerUseBackend): for n in ("progman", "workerw", "program manager", "finder", "desktop") ) else 1, ) - elif app: - app_lower = app.lower() - filtered = [w for w in windows if app_lower in w["app_name"].lower()] + elif pid is None and window_id is None and app: + filtered = self._match_windows_for_app(windows, app) if not filtered: - return CaptureResult( - mode=mode, width=0, height=0, png_b64=None, - elements=[], app="", - window_title=( + return self._failed_capture( + mode, + ( f"" + f"instead of 'Calculator'; some Linux/Qt apps only " + f"resolve via list_apps metadata)>" ), - png_bytes_len=0, ) windows = filtered @@ -1357,11 +1543,18 @@ class CuaDriverBackend(ComputerUseBackend): target = next((w for w in windows if not w["off_screen"]), windows[0]) self._active_pid = target["pid"] self._active_window_id = target["window_id"] + # Tokens belong to the prior window snapshot. Disarm them before any + # capture call so an exception cannot pair old tokens with this target. + self._snapshot_tokens = {} app_name = target["app_name"] # Record the resolved app name so capture_after= follow-ups can re-target # the same app rather than falling back to the frontmost window. if app or not self._last_app: self._last_app = app_name + self._last_target = { + "pid": self._active_pid, + "window_id": self._active_window_id, + } # Step 2: capture. png_b64: Optional[str] = None @@ -1390,7 +1583,7 @@ class CuaDriverBackend(ComputerUseBackend): ) sc_out: Optional[Dict[str, Any]] = None if use_screenshot: - sc_out = self._session.call_tool( + sc_out = self._call_capture_tool( "screenshot", { "window_id": self._active_window_id, @@ -1407,7 +1600,7 @@ class CuaDriverBackend(ComputerUseBackend): sc_out = None if sc_out is None: - gws_out = self._session.call_tool( + gws_out = self._call_capture_tool( "get_window_state", { "pid": self._active_pid, @@ -1444,7 +1637,9 @@ class CuaDriverBackend(ComputerUseBackend): }, 30.0, ) - if cli_out.get("images"): + if cli_out.get("isError") is True: + self._clear_active_target() + elif cli_out.get("images"): png_b64 = cli_out["images"][0] image_mime_type = "image/png" except Exception as cli_exc: @@ -1453,7 +1648,7 @@ class CuaDriverBackend(ComputerUseBackend): ) else: # get_window_state: AX tree + screenshot. - gws_out = self._session.call_tool( + gws_out = self._call_capture_tool( "get_window_state", { "pid": self._active_pid, @@ -1499,7 +1694,9 @@ class CuaDriverBackend(ComputerUseBackend): }, 30.0, ) - if not _gws_is_empty(cli_out): + if cli_out.get("isError") is True: + self._clear_active_target() + elif not _gws_is_empty(cli_out): gws_out = cli_out except Exception as cli_exc: logger.error( @@ -1606,8 +1803,12 @@ class CuaDriverBackend(ComputerUseBackend): args["element_index"] = element args["window_id"] = self._active_window_id elif x is not None and y is not None: + if self._active_window_id is None: + return ActionResult(ok=False, action=tool, + message="No active window_id for coordinate click.") args["x"] = x args["y"] = y + args["window_id"] = self._active_window_id else: return ActionResult(ok=False, action=tool, message="click requires element= or x/y.") @@ -1639,8 +1840,12 @@ class CuaDriverBackend(ComputerUseBackend): args["to_element"] = to_element args["window_id"] = self._active_window_id elif from_xy is not None and to_xy is not None: + if self._active_window_id is None: + return ActionResult(ok=False, action="drag", + message="No active window_id for coordinate drag.") args["from_x"], args["from_y"] = int(from_xy[0]), int(from_xy[1]) args["to_x"], args["to_y"] = int(to_xy[0]), int(to_xy[1]) + args["window_id"] = self._active_window_id else: return ActionResult(ok=False, action="drag", message="drag requires from_element/to_element or from_coordinate/to_coordinate.") @@ -1669,21 +1874,36 @@ class CuaDriverBackend(ComputerUseBackend): args["element_index"] = element args["window_id"] = self._active_window_id elif x is not None and y is not None: - args["x"] = x - args["y"] = y + if self._active_window_id is None: + return ActionResult(ok=False, action="scroll", + message="No active window_id for coordinate scroll.") + # CUA Driver 0.7.1 Linux schema rejects x/y on scroll. Only + # include them when the driver explicitly advertises support + # for coordinate scrolling; otherwise omit and let the driver + # scroll the targeted window (window_id is still sent for + # routing). This is the safe default when capabilities + # haven't been discovered yet (older drivers). + if self._session.supports_capability( + "input.scroll.coordinates", tool="scroll" + ): + args["x"] = x + args["y"] = y + args["window_id"] = self._active_window_id return self._action("scroll", args) # ── Keyboard ─────────────────────────────────────────────────── def type_text(self, text: str) -> ActionResult: pid = self._active_pid - if pid is None: + window_id = self._active_window_id + if pid is None or window_id is None: return ActionResult(ok=False, action="type_text", message="No active window — call capture() first.") - return self._action("type_text", {"pid": pid, "text": text}) + return self._action("type_text", {"pid": pid, "window_id": window_id, "text": text}) def key(self, keys: str) -> ActionResult: pid = self._active_pid - if pid is None: + window_id = self._active_window_id + if pid is None or window_id is None: return ActionResult(ok=False, action="key", message="No active window — call capture() first.") @@ -1694,9 +1914,11 @@ class CuaDriverBackend(ComputerUseBackend): if modifiers: # hotkey requires at least one modifier + one key. - return self._action("hotkey", {"pid": pid, "keys": modifiers + [key_name]}) + return self._action("hotkey", {"pid": pid, "window_id": window_id, + "keys": modifiers + [key_name]}) else: - return self._action("press_key", {"pid": pid, "key": key_name}) + return self._action("press_key", {"pid": pid, "window_id": window_id, + "key": key_name}) # ── Value setter ──────────────────────────────────────────────── def set_value(self, value: str, element: Optional[int] = None) -> ActionResult: @@ -1720,12 +1942,17 @@ class CuaDriverBackend(ComputerUseBackend): # ── Introspection ────────────────────────────────────────────── def list_apps(self) -> List[Dict[str, Any]]: out = self._session.call_tool("list_apps", {"session": self._session_id}) - data = out["data"] + structured = out.get("structuredContent") + if isinstance(structured, dict) and isinstance(structured.get("apps"), list): + return structured["apps"] + + # Older drivers and direct CLI fallbacks may put apps in data instead. + data = out.get("data") if isinstance(data, list): return data - if isinstance(data, dict): - return data.get("apps", []) - # list_apps returns plain text — parse app lines. + if isinstance(data, dict) and isinstance(data.get("apps"), list): + return data["apps"] + # Old text-only drivers retain a small, name/PID-only fallback. if isinstance(data, str): apps = [] for line in data.splitlines(): @@ -1735,6 +1962,9 @@ class CuaDriverBackend(ComputerUseBackend): return apps return [] + def list_windows(self) -> List[Dict[str, Any]]: + return self._load_windows() + def focus_app(self, app: str, raise_window: bool = False) -> ActionResult: """Target an app for subsequent actions without stealing system focus. @@ -1748,16 +1978,13 @@ class CuaDriverBackend(ComputerUseBackend): raise_window=True is intentionally ignored: stealing the user's focus is exactly what this backend is designed to avoid. """ - lw_out = self._session.call_tool( - "list_windows", - {"on_screen_only": True, "session": self._session_id}, - ) - raw_windows = (lw_out.get("structuredContent") or {}).get("windows") or [] - windows = _ingest_windows(raw_windows) - windows.sort(key=lambda w: w["z_index"]) + try: + windows = self._load_windows() + except Exception: + self._clear_active_target() + raise - app_lower = app.lower() - matched = [w for w in windows if app_lower in w["app_name"].lower()] + matched = self._match_windows_for_app(windows, app) # Don't silently fall back to the frontmost window when the filter # matches nothing — that hides the real failure (often a localized # macOS app name mismatch, e.g. caller passed "Calculator" but @@ -1766,12 +1993,18 @@ class CuaDriverBackend(ComputerUseBackend): if target: self._active_pid = target["pid"] self._active_window_id = target["window_id"] - self._last_app = target["app_name"] # preserve for capture_after= follow-ups + self._snapshot_tokens = {} + self._last_app = target["app_name"] # retained for back-compat diagnostics + self._last_target = { + "pid": self._active_pid, + "window_id": self._active_window_id, + } return ActionResult( ok=True, action="focus_app", message=f"Targeted {target['app_name']} (pid {self._active_pid}, " f"window {self._active_window_id}) without raising window.", ) + self._clear_active_target() return ActionResult(ok=False, action="focus_app", message=f"No on-screen window found for app '{app}'.") diff --git a/tools/computer_use/schema.py b/tools/computer_use/schema.py index a3394d23276e..613185d83a2c 100644 --- a/tools/computer_use/schema.py +++ b/tools/computer_use/schema.py @@ -44,6 +44,7 @@ COMPUTER_USE_SCHEMA: Dict[str, Any] = { "set_value", "wait", "list_apps", + "list_windows", "focus_app", ], "description": ( @@ -81,6 +82,21 @@ COMPUTER_USE_SCHEMA: Dict[str, Any] = { "one window or display at a time." ), }, + "pid": { + "type": "integer", + "description": ( + "Optional exact process target for action='capture'. Pair " + "with window_id when discovery cannot resolve an X11 app." + ), + }, + "window_id": { + "type": "integer", + "description": ( + "Optional exact native window target for action='capture'. " + "Pair with pid when an external cua-driver list_windows " + "lookup has already identified the window." + ), + }, "max_elements": { "type": "integer", "description": ( @@ -118,9 +134,9 @@ COMPUTER_USE_SCHEMA: Dict[str, Any] = { "minItems": 2, "maxItems": 2, "description": ( - "Pixel coordinates [x, y] in logical screen space (as " - "returned by capture width/height). Only use this if " - "no element index is available." + "Pixel coordinates [x, y] relative to the captured window " + "screenshot (top-left origin). Only use this if no element " + "index is available." ), }, "button": { diff --git a/tools/computer_use/tool.py b/tools/computer_use/tool.py index 6d6902169161..fec551b6075b 100644 --- a/tools/computer_use/tool.py +++ b/tools/computer_use/tool.py @@ -193,8 +193,17 @@ class _NoopBackend(ComputerUseBackend): # pragma: no cover def stop(self) -> None: self._started = False def is_available(self) -> bool: return True - def capture(self, mode: str = "som", app: Optional[str] = None) -> CaptureResult: - self.calls.append(("capture", {"mode": mode, "app": app})) + def capture( + self, + mode: str = "som", + app: Optional[str] = None, + pid: Optional[int] = None, + window_id: Optional[int] = None, + ) -> CaptureResult: + self.calls.append(( + "capture", + {"mode": mode, "app": app, "pid": pid, "window_id": window_id}, + )) return CaptureResult(mode=mode, width=1024, height=768, png_b64=None, elements=[], app=app or "", window_title="") @@ -222,6 +231,10 @@ class _NoopBackend(ComputerUseBackend): # pragma: no cover self.calls.append(("list_apps", {})) return [] + def list_windows(self) -> List[Dict[str, Any]]: + self.calls.append(("list_windows", {})) + return [] + def focus_app(self, app: str, raise_window: bool = False) -> ActionResult: self.calls.append(("focus_app", {"app": app, "raise": raise_window})) return ActionResult(ok=True, action="focus_app") @@ -347,7 +360,13 @@ def _dispatch(backend: ComputerUseBackend, action: str, args: Dict[str, Any]) -> mode = str(args.get("mode", "som")) if mode not in {"som", "vision", "ax"}: return json.dumps({"error": f"bad mode {mode!r}; use som|vision|ax"}) - cap = backend.capture(mode=mode, app=args.get("app")) + capture_kwargs: Dict[str, Any] = {"mode": mode, "app": args.get("app")} + if args.get("pid") is not None or args.get("window_id") is not None: + capture_kwargs.update({ + "pid": args.get("pid"), + "window_id": args.get("window_id"), + }) + cap = backend.capture(**capture_kwargs) return _capture_response(cap, max_elements=_coerce_max_elements(args.get("max_elements"))) if action == "wait": @@ -359,6 +378,10 @@ def _dispatch(backend: ComputerUseBackend, action: str, args: Dict[str, Any]) -> apps = backend.list_apps() return json.dumps({"apps": apps, "count": len(apps)}) + if action == "list_windows": + windows = backend.list_windows() + return json.dumps({"windows": windows, "count": len(windows)}) + if action == "focus_app": app = args.get("app") if not app: @@ -844,11 +867,16 @@ def _maybe_follow_capture( if not res.ok: return _text_response(res) try: - # Preserve the app context established by the preceding capture/focus_app so - # that capture_after=True re-captures the same app rather than the frontmost - # window (which may have changed if the action caused a focus shift). - last_app = getattr(backend, "_last_app", None) - cap = backend.capture(mode="som", app=last_app) + # Preserve the exact selected window when possible. Linux may expose a + # generic app name for several unrelated windows, so app-only recapture + # can silently switch targets after a successful action. + target = getattr(backend, "_last_target", None) or {} + pid = target.get("pid") + window_id = target.get("window_id") + if pid is not None and window_id is not None: + cap = backend.capture(mode="som", pid=pid, window_id=window_id) + else: + cap = backend.capture(mode="som", app=getattr(backend, "_last_app", None)) except Exception as e: logger.warning("follow-up capture failed: %s", e) return _text_response(res) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 911e25204ab6..c8f6fa5293a9 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1055,6 +1055,8 @@ def _build_child_agent( override_base_url: Optional[str] = None, override_api_key: Optional[str] = None, override_api_mode: Optional[str] = None, + override_request_overrides: Optional[Dict[str, Any]] = None, + override_max_tokens: Optional[int] = None, # ACP transport overrides from trusted delegation config. override_acp_command: Optional[str] = None, override_acp_args: Optional[List[str]] = None, @@ -1288,16 +1290,33 @@ def _build_child_agent( child_providers_ignored = getattr(parent_agent, "providers_ignored", None) child_providers_order = getattr(parent_agent, "providers_order", None) child_provider_sort = getattr(parent_agent, "provider_sort", None) + child_provider_require_parameters = getattr( + parent_agent, "provider_require_parameters", False + ) + child_provider_data_collection = getattr( + parent_agent, "provider_data_collection", None + ) or "" child_openrouter_min_coding_score = getattr(parent_agent, "openrouter_min_coding_score", None) if override_provider: child_providers_allowed = None child_providers_ignored = None child_providers_order = None child_provider_sort = None + child_provider_require_parameters = False + child_provider_data_collection = "" # Note: openrouter_min_coding_score is model-gated (only emitted on # openrouter/pareto-code), so we keep it inherited even when the # provider is overridden — it's a no-op on any other model. + child_max_tokens = ( + override_max_tokens + if override_max_tokens is not None + else getattr(parent_agent, "max_tokens", None) + ) + child_optional_kwargs: Dict[str, Any] = {} + if isinstance(child_max_tokens, int): + child_optional_kwargs["max_tokens"] = child_max_tokens + child = AIAgent( base_url=effective_base_url, api_key=effective_api_key, @@ -1307,7 +1326,7 @@ def _build_child_agent( acp_command=effective_acp_command, acp_args=effective_acp_args, max_iterations=max_iterations, - max_tokens=getattr(parent_agent, "max_tokens", None), + reasoning_config=child_reasoning, prefill_messages=getattr(parent_agent, "prefill_messages", None), fallback_model=parent_fallback, @@ -1326,9 +1345,17 @@ def _build_child_agent( providers_ignored=child_providers_ignored, providers_order=child_providers_order, provider_sort=child_provider_sort, + provider_require_parameters=child_provider_require_parameters, + provider_data_collection=child_provider_data_collection, + request_overrides=( + dict(override_request_overrides or {}) + if override_provider + else dict(getattr(parent_agent, "request_overrides", {}) or {}) + ), openrouter_min_coding_score=child_openrouter_min_coding_score, tool_progress_callback=child_progress_cb, iteration_budget=None, # fresh budget per subagent + **child_optional_kwargs, ) child._print_fn = getattr(parent_agent, "_print_fn", None) # Now the child exists, its session id can ride on every relayed event @@ -2500,6 +2527,8 @@ def delegate_task( override_base_url=creds["base_url"], override_api_key=creds["api_key"], override_api_mode=creds["api_mode"], + override_request_overrides=creds.get("request_overrides"), + override_max_tokens=creds.get("max_output_tokens"), override_acp_command=creds.get("command"), override_acp_args=creds.get("args"), role=effective_role, @@ -2815,6 +2844,20 @@ def delegate_task( _session_key = _agent_session_id except Exception: _origin_ui_session_id = "" + if not _session_key: + # CLI (single-process) path: the approval contextvar is only bound + # during gateway/TUI turns and HERMES_SESSION_KEY is not in the CLI + # environment, so the key resolves empty here. Since #64240 the CLI + # drains completions through a positive-ownership filter keyed on + # the durable AIAgent.session_id — an empty session_key would fail + # closed and the CLI could never claim its own completions, while + # a restored foreign event with an empty key could leak into any + # unfiltered consumer (#64484). Stamp the parent's durable session + # id instead; compression rotations are handled on the drain side + # via resolve_resume_session_id lineage resolution. + _agent_session_id = str(getattr(parent_agent, "session_id", "") or "") + if _agent_session_id: + _session_key = _agent_session_id _parent_session_id = getattr(parent_agent, "session_id", None) _child_agents = [c for (_, _, c) in children] @@ -3086,6 +3129,8 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: "base_url": None, "api_key": None, "api_mode": None, + "request_overrides": None, + "max_output_tokens": None, } # Provider is configured — resolve full credentials @@ -3114,6 +3159,8 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: "base_url": runtime.get("base_url"), "api_key": api_key, "api_mode": runtime.get("api_mode"), + "request_overrides": dict(runtime.get("request_overrides") or {}), + "max_output_tokens": runtime.get("max_output_tokens"), "command": runtime.get("command"), "args": list(runtime.get("args") or []), } diff --git a/tools/environments/base.py b/tools/environments/base.py index 93d56c0c5ced..87049fa954dd 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -17,6 +17,7 @@ import threading import time import uuid from abc import ABC, abstractmethod +from collections import deque from pathlib import Path from typing import IO, Callable, Protocol @@ -44,6 +45,105 @@ if _DEBUG_INTERRUPT: _activity_callback_local = threading.local() +# Sentinel capacity for full-fidelity capture (internal consumers). Large +# enough that the collector never evicts in practice, keeping a single code +# path for both bounded and unbounded modes. +_UNBOUNDED_CAPTURE_CHARS = 2**63 - 1 + + +class _BoundedOutputCollector: + """Retain a bounded 40/60 head-tail window of streamed text.""" + def __init__(self, max_chars: int): + self.max_chars = max(1, int(max_chars)) + self._head_limit = int(self.max_chars * 0.4) + self._tail_limit = self.max_chars - self._head_limit + self._head: list[str] = [] + self._tail: deque[str] = deque() + self._head_chars = 0 + self._tail_chars = 0 + self._total_chars = 0 + self._lock = threading.Lock() + + @property + def buffered_chars(self) -> int: + with self._lock: + return self._head_chars + self._tail_chars + + @property + def total_chars(self) -> int: + with self._lock: + return self._total_chars + + def append(self, text: str) -> None: + if not text: + return + with self._lock: + text_len = len(text) + self._total_chars += text_len + start = 0 + + if self._head_chars < self._head_limit: + take = min(self._head_limit - self._head_chars, text_len) + if take: + self._head.append(text[:take]) + self._head_chars += take + start = take + + remaining = text_len - start + if remaining <= 0 or self._tail_limit <= 0: + return + if remaining >= self._tail_limit: + self._tail.clear() + self._tail.append(text[-self._tail_limit :]) + self._tail_chars = self._tail_limit + return + + chunk = text[start:] + self._tail.append(chunk) + self._tail_chars += len(chunk) + while self._tail_chars > self._tail_limit: + excess = self._tail_chars - self._tail_limit + first = self._tail[0] + if len(first) <= excess: + self._tail.popleft() + self._tail_chars -= len(first) + else: + self._tail[0] = first[excess:] + self._tail_chars -= excess + + def render(self, *, suffix: str = "") -> str: + """Render within ``max_chars``, preserving a required status suffix.""" + with self._lock: + if len(suffix) >= self.max_chars: + return suffix[-self.max_chars :] + + head = "".join(self._head) + tail = "".join(self._tail) + available = self.max_chars - len(suffix) + if self._total_chars <= available: + return head + tail + suffix + + notice = "" + for _ in range(4): + content_budget = max(0, available - len(notice)) + head_chars = int(content_budget * 0.4) + tail_chars = content_budget - head_chars + omitted = max(0, self._total_chars - head_chars - tail_chars) + updated = ( + f"\n\n... [OUTPUT TRUNCATED - {omitted:,} chars omitted " + f"out of {self._total_chars:,} total] ...\n\n" + ) + if updated == notice: + break + notice = updated + + content_budget = max(0, available - len(notice)) + head_chars = int(content_budget * 0.4) + tail_chars = content_budget - head_chars + rendered_tail = tail[-tail_chars:] if tail_chars else "" + return head[:head_chars] + notice[:available] + rendered_tail + suffix + + def set_activity_callback(cb: Callable[[str], None] | None) -> None: """Register a callback that _wait_for_process fires periodically.""" _activity_callback_local.callback = cb @@ -321,6 +421,10 @@ class BaseEnvironment(ABC): self._cwd_file = f"{temp_dir}/hermes-cwd-{self._session_id}.txt" self._cwd_marker = _cwd_marker(self._session_id) self._snapshot_ready = False + # When True, login bash is unusable (e.g. broken Git-for-Windows + # ``Directory \\drivers\\etc`` startup) so execute() must not fall + # back to ``bash -l`` per command — use non-login ``bash -c`` instead. + self._prefer_nonlogin = False # ------------------------------------------------------------------ # Abstract methods @@ -367,15 +471,14 @@ class BaseEnvironment(ABC): # Without this the snapshot bootstrap ``cd`` below fails on Windows and # ``pwd -P`` captures the login shell's directory, not ``terminal.cwd``. _quoted_cwd = self._quote_cwd_for_cd(self.cwd) - # Quote the snapshot / cwd-file paths so Git Bash on Windows handles - # ``C:/Users/...``-shaped paths without glob-splitting the colon or - # tripping on drive letters. On POSIX this is a no-op (no colons / - # special chars in a /tmp path). Previously unquoted interpolation - # caused ``C:/Users/.../hermes-snap-*.sh: No such file or directory`` - # errors on Windows, leaking via stderr (merged into stdout on Linux - # backends) into every terminal-tool response. - _quoted_snap = shlex.quote(self._snapshot_path) - _quoted_cwd_file = shlex.quote(self._cwd_file) + # Quote snapshot / cwd-file paths via ``_quote_shell_path`` so the + # LocalEnvironment override can rewrite ``C:/...`` (and mixed + # ``/c/Users\\...``) to ``/c/...`` before quoting — bare drive paths + # in the bootstrap script trip MSYS into the + # ``Directory \\drivers\\etc does not exist`` failure class. + # On POSIX this is plain ``shlex.quote``. + _quoted_snap = self._quote_shell_path(self._snapshot_path) + _quoted_cwd_file = self._quote_shell_path(self._cwd_file) # Use atomic file replacement: assemble the snapshot in a temp file, # then mv it over the final path. This prevents concurrent source() # calls from reading a half-written snapshot when another terminal @@ -391,9 +494,9 @@ class BaseEnvironment(ABC): # mid-write, and mv would then publish a torn file (the corruption is # only narrowed, not closed). ``$BASHPID`` is the actual subshell PID # and is genuinely unique per writer, which closes the race. The - # static path is shlex-quoted (Windows/Git-Bash drive letters, spaces) + # static path is shell-quoted (Windows/Git-Bash drive letters, spaces) # with ``$BASHPID`` left outside the quotes so it still expands. - _snap_tmp = shlex.quote(self._snapshot_path + ".tmp.") + "$BASHPID" + _snap_tmp = self._quote_shell_path(self._snapshot_path + ".tmp.") + "$BASHPID" bootstrap = ( f"umask 077\n" f"export -p > {_snap_tmp}\n" @@ -438,13 +541,37 @@ class BaseEnvironment(ABC): self.cwd, ) except Exception as exc: - logger.warning( - "init_session failed (session=%s): %s — " - "falling back to bash -l per command", - self._session_id, - exc, - ) self._snapshot_ready = False + # Default fallback is bash -l per command so PATH/nvm/etc still + # load. If login itself is dead (classic Windows Git Bash + # ``Directory \\drivers\\etc does not exist``), that fallback + # would brick every tool — prefer non-login bash -c instead. + detail = str(exc) + prefer_nonlogin = False + try: + probe = self._run_bash("true", login=False, timeout=min(15, self._snapshot_timeout)) + probe_result = self._wait_for_process(probe, timeout=min(15, self._snapshot_timeout)) + prefer_nonlogin = int(probe_result.get("returncode") or 0) == 0 + if not prefer_nonlogin: + detail = (probe_result.get("stdout") or detail).strip() or detail + except Exception as probe_exc: + detail = f"{detail}; non-login probe: {probe_exc}" + + self._prefer_nonlogin = prefer_nonlogin + if prefer_nonlogin: + logger.warning( + "init_session failed (session=%s): %s — " + "login bash unusable; falling back to non-login bash -c", + self._session_id, + exc, + ) + else: + logger.warning( + "init_session failed (session=%s): %s — " + "falling back to bash -l per command", + self._session_id, + detail, + ) # ------------------------------------------------------------------ # Command wrapping @@ -461,25 +588,32 @@ class BaseEnvironment(ABC): return f"$HOME/{shlex.quote(cwd[2:])}" return shlex.quote(cwd) + def _quote_shell_path(self, path: str) -> str: + """Quote *path* for interpolation into a bash script. + + LocalEnvironment overrides this to rewrite native/mixed Windows + paths to ``/c/...`` before quoting. Remote backends leave paths + as-is (they already speak POSIX). + """ + return shlex.quote(path) + def _wrap_command(self, command: str, cwd: str) -> str: """Build the full bash script that sources snapshot, cd's, runs command, re-dumps env vars, and emits CWD markers.""" escaped = command.replace("'", "'\\''") - # Quote the snapshot / cwd-file paths so Git Bash on Windows handles - # ``C:/Users/...``-shaped paths without glob-splitting the colon or - # tripping on drive letters. POSIX paths are unaffected. See - # :meth:`init_session` for the same fix on the bootstrap block. - _quoted_snap = shlex.quote(self._snapshot_path) - _quoted_cwd_file = shlex.quote(self._cwd_file) + # Quote snapshot/cwd-file paths (see init_session — LocalEnvironment + # rewrites ``C:/...`` to ``/c/...`` so MSYS doesn't mangle them). + _quoted_snap = self._quote_shell_path(self._snapshot_path) + _quoted_cwd_file = self._quote_shell_path(self._cwd_file) # Use atomic file replacement for env snapshot updates (issue #38249). # Assemble into a per-writer-unique temp file, then mv to atomically # replace the snapshot so concurrent source() calls never read a # truncated/half-written file. ``$BASHPID`` (not ``$$``) is the actual # subshell PID — unique per concurrent ``&``-launched writer — so two # writers never share a temp name and clobber each other before the mv. - # Static path shlex-quoted (Windows/spaces); ``$BASHPID`` left to expand. - _snap_tmp = shlex.quote(self._snapshot_path + ".tmp.") + "$BASHPID" + # Static path shell-quoted (Windows/spaces); ``$BASHPID`` left to expand. + _snap_tmp = self._quote_shell_path(self._snapshot_path + ".tmp.") + "$BASHPID" parts = [] @@ -544,11 +678,21 @@ class BaseEnvironment(ABC): # Process lifecycle # ------------------------------------------------------------------ - def _wait_for_process(self, proc: ProcessHandle, timeout: int = 120) -> dict: + def _wait_for_process( + self, proc: ProcessHandle, timeout: int = 120, *, bounded_capture: bool = False + ) -> dict: """Poll-based wait with interrupt checking and stdout draining. Shared across all backends — not overridden. + ``bounded_capture=True`` (foreground terminal-tool path only) retains + at most ``tool_output.max_bytes`` of output in a head/tail window + while draining, so a verbose subprocess cannot OOM the process + (#64435). The default (False) preserves full-fidelity capture for + internal consumers — file-operation ``cat`` reads feeding the patch + engine, code-execution RPC reads, log reads — where truncation would + corrupt data. + Fires the ``activity_callback`` (if set on this instance) every 10s while the process is running so the gateway's inactivity timeout doesn't kill long-running commands. @@ -560,7 +704,19 @@ class BaseEnvironment(ABC): an orphan with ``PPID=1`` when python is shut down mid-tool — the ``sleep 300``-survives-30-min bug Physikal and I both hit. """ - output_chunks: list[str] = [] + if bounded_capture: + try: + from tools.tool_output_limits import get_max_bytes + + capture_limit = get_max_bytes() + except Exception: + capture_limit = 50_000 + else: + # Full fidelity: effectively unbounded collector (single head + # segment, no eviction) so behavior matches the historical + # accumulate-everything semantics. + capture_limit = _UNBOUNDED_CAPTURE_CHARS + output = _BoundedOutputCollector(capture_limit) # Non-blocking drain via select(). # @@ -601,16 +757,16 @@ class BaseEnvironment(ABC): if piece is None: continue if isinstance(piece, bytes): - output_chunks.append(decoder.decode(piece)) + output.append(decoder.decode(piece)) else: - output_chunks.append(str(piece)) + output.append(str(piece)) except Exception: pass finally: try: tail = decoder.decode(b"", final=True) if tail: - output_chunks.append(tail) + output.append(tail) except Exception: pass @@ -641,14 +797,14 @@ class BaseEnvironment(ABC): chunk = os.read(fd, 4096) if not chunk: break - output_chunks.append(decoder.decode(chunk)) + output.append(decoder.decode(chunk)) except (ValueError, OSError): pass finally: try: tail = decoder.decode(b"", final=True) if tail: - output_chunks.append(tail) + output.append(tail) except Exception: pass return @@ -666,7 +822,7 @@ class BaseEnvironment(ABC): break if not chunk: break # true EOF — all writers closed - output_chunks.append(decoder.decode(chunk)) + output.append(decoder.decode(chunk)) idle_after_exit = 0 elif proc.poll() is not None: # bash is gone and the pipe was idle for ~100ms. Give @@ -682,7 +838,7 @@ class BaseEnvironment(ABC): try: tail = decoder.decode(b"", final=True) if tail: - output_chunks.append(tail) + output.append(tail) except Exception: pass @@ -728,7 +884,7 @@ class BaseEnvironment(ABC): self._kill_process(proc) drain_thread.join(timeout=2) return { - "output": "".join(output_chunks) + "\n[Command interrupted]", + "output": output.render(suffix="\n[Command interrupted]"), "returncode": 130, } if time.monotonic() > deadline: @@ -740,12 +896,11 @@ class BaseEnvironment(ABC): ) self._kill_process(proc) drain_thread.join(timeout=2) - partial = "".join(output_chunks) timeout_msg = f"\n[Command timed out after {timeout}s]" return { - "output": partial + timeout_msg - if partial - else timeout_msg.lstrip(), + "output": output.render(suffix=timeout_msg).lstrip() + if output.total_chars == 0 + else output.render(suffix=timeout_msg), "returncode": 124, } # Periodic activity touch so the gateway knows we're alive @@ -821,7 +976,7 @@ class BaseEnvironment(ABC): proc.returncode, ) - return {"output": "".join(output_chunks), "returncode": proc.returncode} + return {"output": output.render(), "returncode": proc.returncode} def _kill_process(self, proc: ProcessHandle): """Terminate a process. Subclasses may override for process-group kill.""" @@ -898,8 +1053,19 @@ class BaseEnvironment(ABC): timeout: int | None = None, stdin_data: str | None = None, rewrite_compound_background: bool = True, + bounded_capture: bool = False, ) -> dict: - """Execute a command, return {"output": str, "returncode": int}.""" + """Execute a command, return {"output": str, "returncode": int}. + + ``bounded_capture=True`` caps stdout/stderr retention at + ``tool_output.max_bytes`` WHILE the stream is drained (head/tail + window) instead of holding the full output in memory (#64435). + It must only be set by callers whose output is destined for the + model/tool payload (the foreground terminal tool). Internal + full-fidelity consumers — file operations ``cat`` reads that feed + the patch engine, code-execution RPC reads, log reads — MUST leave + it False: truncating those corrupts data, not just display. + """ self._before_execute() exec_command, sudo_stdin = self._prepare_command(command) @@ -927,13 +1093,16 @@ class BaseEnvironment(ABC): wrapped = self._wrap_command(exec_command, effective_cwd) - # Use login shell if snapshot failed (so user's profile still loads) - login = not self._snapshot_ready + # Use login shell if snapshot failed (so user's profile still loads), + # unless login itself is broken — then non-login is the only path. + login = not self._snapshot_ready and not self._prefer_nonlogin proc = self._run_bash( wrapped, login=login, timeout=effective_timeout, stdin_data=effective_stdin ) - result = self._wait_for_process(proc, timeout=effective_timeout) + result = self._wait_for_process( + proc, timeout=effective_timeout, bounded_capture=bounded_capture + ) self._update_cwd(result) return result diff --git a/tools/environments/local.py b/tools/environments/local.py index 191ff4d4b2da..0a867fa984bd 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -25,16 +25,24 @@ def _msys_to_windows_path(cwd: str) -> str: native Windows form (``C:\\Users\\x``) so ``os.path.isdir`` and ``subprocess.Popen(..., cwd=...)`` can find it. + Also accepts the Cygwin (``/cygdrive/c/...``) and WSL-mount + (``/mnt/c/...``) spellings of a drive root. Multi-segment POSIX paths + like ``/home/x`` or ``/tmp/foo`` are left untouched. + No-ops on non-Windows hosts or for paths that aren't in MSYS form. Returns the input unchanged when no translation applies. This is idempotent — calling it on an already-Windows path returns it as-is. """ if not _IS_WINDOWS or not cwd: return cwd - # Match leading "//" or exactly "/" (bare drive root). - m = re.match(r'^/([a-zA-Z])(/.*)?$', cwd) + # Match leading "//" or exactly "/" (bare drive root), + # plus /cygdrive//... and /mnt//... variants. + m = re.match(r'^/(?:(?:cygdrive|mnt)/)?([a-zA-Z])(/.*)?$', cwd) if not m: return cwd + # Reject /cygdrive or /mnt with no drive letter — the optional group above + # already requires the letter. Multi-char first segments (/home, /tmp) + # fail the single-letter capture and fall through as no-ops. drive = m.group(1).upper() tail = (m.group(2) or "").replace('/', '\\') return f"{drive}:{tail or chr(92)}" # chr(92) = backslash, avoid raw-string escape @@ -58,6 +66,35 @@ def _windows_to_msys_path(cwd: str) -> str: return f"/{drive}/{tail}" if tail else f"/{drive}/" +def _bash_safe_path(path: str) -> str: + """Return *path* in a form safe to embed in a Git Bash script. + + Native ``C:\\Users\\x`` / ``C:/Users/x`` → ``/c/Users/x`` via + :func:`_windows_to_msys_path`. Mixed MSYS leftovers + (``/c/Users\\Alexander\\Documents``) get backslashes normalized so + bash does not eat ``\\U`` and trip the ``Directory \\drivers\\etc`` + failure class. No-op off Windows and for empty input. + + ``get_temp_dir`` already emits forward-slash ``C:/...`` forms for + Python compatibility; those still need the ``/c/...`` rewrite — + MSYS argument conversion treats ``C:/...`` as a Windows path and + can corrupt the login-shell ``drivers\\etc`` lookup. + """ + if not _IS_WINDOWS or not path: + return path + path = _windows_to_msys_path(path) + if "\\" in path: + path = path.replace("\\", "/") + return path + + +def _quote_bash_path(path: str) -> str: + """Quote *path* for safe interpolation into a Git Bash script on Windows.""" + import shlex + + return shlex.quote(_bash_safe_path(path)) + + def _resolve_safe_cwd(cwd: str) -> str: """Return ``cwd`` if it exists as a directory, else the nearest existing ancestor. Falls back to ``tempfile.gettempdir()`` only if walking up the @@ -521,14 +558,15 @@ def _find_bash() -> str: or "/bin/sh" ) + candidates: list[str] = [] + custom = os.environ.get("HERMES_GIT_BASH_PATH") if custom and os.path.isfile(custom): - return custom + candidates.append(custom) - # Prefer our own portable Git install first — this way a broken or - # partially-uninstalled system Git can't hijack the bash lookup. The - # install.ps1 installer always drops portable Git here when the user - # didn't already have a working system Git. + # Prefer our own portable Git install — a broken or partially-uninstalled + # system Git (or a stale HERMES_GIT_BASH_PATH pointing at one) must not + # brick the terminal. install.ps1 drops PortableGit here when needed. # # Layouts (both checked so upgrades between MinGit and PortableGit # installs work transparently): @@ -541,8 +579,8 @@ def _find_bash() -> str: os.path.join(_hermes_portable_git, "bin", "bash.exe"), # PortableGit (primary) os.path.join(_hermes_portable_git, "usr", "bin", "bash.exe"), # MinGit fallback ): - if os.path.isfile(candidate): - return candidate + if os.path.isfile(candidate) and candidate not in candidates: + candidates.append(candidate) # Check known Git for Windows install locations before PATH lookup. # On machines with both WSL and Git for Windows, shutil.which("bash") @@ -551,14 +589,33 @@ def _find_bash() -> str: for candidate in ( os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Git", "bin", "bash.exe"), os.path.join(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), "Git", "bin", "bash.exe"), - os.path.join(_local_appdata, "Programs", "Git", "bin", "bash.exe"), + os.path.join(_local_appdata, "Programs", "Git", "bin", "bash.exe") if _local_appdata else "", ): - if candidate and os.path.isfile(candidate): - return candidate + if candidate and os.path.isfile(candidate) and candidate not in candidates: + candidates.append(candidate) found = shutil.which("bash") - if found: - return found + if found and found not in candidates: + candidates.append(found) + + # Prefer the first candidate that can actually start. A stale + # HERMES_GIT_BASH_PATH pointing at a broken Git-for-Windows install + # (``Directory \\drivers\\etc does not exist``) must not win over a + # healthy portable Git under %LOCALAPPDATA%\\hermes\\git. + for candidate in candidates: + if _bash_starts(candidate): + if candidate != custom and custom and os.path.isfile(custom): + logger.warning( + "HERMES_GIT_BASH_PATH=%s fails to start; using %s instead", + custom, + candidate, + ) + return candidate + + if candidates: + # Last resort: return the first path even if the probe failed, so the + # caller still sees the real bash error instead of "not found". + return candidates[0] raise RuntimeError( "Git Bash not found. Hermes Agent requires Git for Windows on Windows.\n" @@ -567,6 +624,116 @@ def _find_bash() -> str: ) +_bash_starts_cache: dict[str, bool] = {} + + +def _bash_starts(bash: str) -> bool: + """True if *bash* can run a trivial non-login command. + + Uses ``--noprofile --norc`` so a broken login post-install + (``Directory \\drivers\\etc``) does not falsely condemn an otherwise + usable bash. Cached per path for the process lifetime. + """ + cached = _bash_starts_cache.get(bash) + if cached is not None: + return cached + + try: + result = subprocess.run( + [bash, "--noprofile", "--norc", "-c", "exit 0"], + capture_output=True, + text=True, + timeout=15, + creationflags=windows_hide_flags() if _IS_WINDOWS else 0, + ) + ok = result.returncode == 0 + if not ok: + combined = f"{result.stdout or ''}{result.stderr or ''}" + logger.debug("bash probe failed for %s: %s", bash, combined.strip()[:200]) + except Exception as exc: + logger.debug("bash probe error for %s: %s", bash, exc) + ok = False + + _bash_starts_cache[bash] = ok + return ok + + +_git_bash_bin_dirs_cache: "list[str] | None" = None + + +def _git_bash_bin_dirs() -> list[str]: + """Git Bash's coreutils/binary dirs, in ``/etc/profile`` precedence order. + + A non-login ``bash -c`` (the fallback used when ``bash -l`` is broken — + the classic Windows ``Directory \\drivers\\etc does not exist`` failure) + never sources ``/etc/profile``, so it never gets ``…\\usr\\bin`` on PATH. + That directory holds every coreutil the file/terminal tools shell out to + (``cat``, ``mktemp``, ``mv``, ``wc``, ``head``, ``stat``, ``chmod``, + ``mkdir``, ``find`` …). Without it, ``write_file`` fails with an empty + error (the failure text went to a missing binary's stderr) and terminal + commands exit 127. We derive these dirs from the resolved ``bash.exe`` so + the fallback shell can find coreutils regardless of the login shell. + + Returns ``[]`` off Windows or when bash can't be located. Dirs are + returned in the order Git Bash's own ``/etc/profile`` prepends them + (mingw first, then usr/bin, then bin) and only if they exist on disk. + """ + global _git_bash_bin_dirs_cache + if _git_bash_bin_dirs_cache is not None: + return _git_bash_bin_dirs_cache + + if not _IS_WINDOWS: + _git_bash_bin_dirs_cache = [] + return _git_bash_bin_dirs_cache + + dirs: list[str] = [] + try: + bash = _find_bash() + except Exception: + _git_bash_bin_dirs_cache = [] + return _git_bash_bin_dirs_cache + + bin_dir = os.path.dirname(bash) # \bin or \usr\bin + parent = os.path.dirname(bin_dir) + # MinGit ships bash under usr\bin; PortableGit/system Git under bin. + root = os.path.dirname(parent) if os.path.basename(parent).lower() == "usr" else parent + + # Order mirrors Git-for-Windows /etc/profile so coreutils win over the + # same-named Windows System32 tools (find.exe, sort.exe) inside the shell. + for candidate in ( + os.path.join(root, "mingw64", "bin"), + os.path.join(root, "mingw32", "bin"), + os.path.join(root, "usr", "local", "bin"), + os.path.join(root, "usr", "bin"), + os.path.join(root, "bin"), + ): + if os.path.isdir(candidate) and candidate not in dirs: + dirs.append(candidate) + + _git_bash_bin_dirs_cache = dirs + return dirs + + +def _prepend_git_bash_dirs(existing_path: str) -> str: + """Prepend Git Bash's binary dirs to ``existing_path`` if missing. + + No-op off Windows or when the dirs can't be resolved. First-occurrence + wins, so a PATH that already lists a dir keeps its position. This is what + lets the non-login ``bash -c`` fallback find coreutils; in the healthy + case the session snapshot re-exports the full login PATH inside the shell, + so this only matters when that snapshot is absent. + """ + git_dirs = _git_bash_bin_dirs() + if not git_dirs: + return existing_path + sep = os.pathsep + entries = [e for e in existing_path.split(sep) if e] if existing_path else [] + missing = [d for d in git_dirs if d not in entries] + if not missing: + return existing_path + return sep.join([*missing, *entries]) + + # POSIX-sh-family shells that understand the ``[shell, "-lic", "set +m; …"]`` # invocation spawn_local uses. $SHELL values outside this set (fish, csh/tcsh, # nushell, elvish, xonsh, …) would error on that syntax, so _find_shell falls @@ -813,6 +980,13 @@ def _make_run_env(env: dict) -> dict: path_key = _path_env_key(run_env) if path_key is not None: new_path = _append_missing_sane_path_entries(run_env.get(path_key, "")) + # On Windows, ensure Git Bash's coreutils dirs (…\usr\bin etc.) are on + # PATH. A non-login ``bash -c`` fallback (used when ``bash -l`` is + # broken) never sources /etc/profile, so without this cat/mktemp/mv and + # friends are missing and every write_file/terminal call fails (empty + # error / exit 127). No-op off Windows and when a login snapshot is + # healthy (the snapshot re-exports the full PATH inside the shell). + new_path = _prepend_git_bash_dirs(new_path) # Ensure the hermes install dir is reachable so plugins can shell out # to bare ``hermes`` via the terminal tool even when the gateway was # launched without it on PATH (systemd, service managers, cron, etc.). @@ -986,6 +1160,10 @@ class LocalEnvironment(BaseEnvironment): """Use native paths for Python, but Git Bash-friendly paths for cd.""" return BaseEnvironment._quote_cwd_for_cd(_windows_to_msys_path(cwd)) + def _quote_shell_path(self, path: str) -> str: + """Rewrite native/mixed Windows paths before quoting for Git Bash.""" + return _quote_bash_path(path) + def _run_bash(self, cmd_string: str, *, login: bool = False, timeout: int = 120, stdin_data: str | None = None) -> subprocess.Popen: diff --git a/tools/environments/modal_utils.py b/tools/environments/modal_utils.py index f83c0075ee3e..167ac3856cdf 100644 --- a/tools/environments/modal_utils.py +++ b/tools/environments/modal_utils.py @@ -80,11 +80,17 @@ class BaseModalExecutionEnvironment(BaseEnvironment): timeout: int | None = None, stdin_data: str | None = None, rewrite_compound_background: bool = True, + bounded_capture: bool = False, ) -> dict: # Managed/remote modal transports execute commands via explicit transport # and do not rely on shell background rewriters. Keep parameter for # compatibility with BaseEnvironment callers. _ = rewrite_compound_background + # bounded_capture: accepted for BaseEnvironment.execute() signature + # parity (the terminal tool passes it). Modal transports return the + # remote function's result in one payload, so streaming-time bounding + # does not apply; the terminal tool's final truncation still caps it. + _ = bounded_capture self._before_execute() prepared = self._prepare_modal_exec( command, diff --git a/tools/file_operations.py b/tools/file_operations.py index 35b60b0b7417..994723cf583b 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -37,6 +37,7 @@ from tools.binary_extensions import BINARY_EXTENSIONS from agent.file_safety import ( build_write_denied_paths, build_write_denied_prefixes, + get_write_denied_error, is_write_denied as _shared_is_write_denied, ) @@ -958,7 +959,19 @@ class ShellFileOperations(FileOperations): return path def _escape_shell_arg(self, arg: str) -> str: - """Escape a string for safe use in shell commands.""" + """Escape a string for safe use in shell commands. + + On Windows native drive paths (``C:\\Users\\x`` / ``C:/Users/x``) + and mixed MSYS leftovers (``/c/Users\\x``) are rewritten to the + Git Bash ``/c/Users/x`` form via ``_bash_safe_path``: bash eats + backslashes and MSYS otherwise mangles drive paths into the + ``Directory \\drivers\\etc does not exist`` failure class. Reuses + the env-layer translator so shell file ops and the terminal ``cd`` + agree on the path form. No-op off Windows and for plain POSIX paths. + """ + from tools.environments.local import _bash_safe_path + + arg = _bash_safe_path(arg) # Use single quotes and escape any single quotes in the string return "'" + arg.replace("'", "'\"'\"'") + "'" @@ -1277,8 +1290,9 @@ class ShellFileOperations(FileOperations): def _python_delete(self, path: str, recursive: bool) -> WriteResult: path = self._expand_path(path) - if _is_write_denied(path): - return WriteResult(error=f"Delete denied: {path} is a protected path") + denied = get_write_denied_error(path, verb="Delete") + if denied: + return WriteResult(error=denied) # We can't shell out to ``rm`` here — it doesn't exist on Windows # ``cmd.exe`` or PowerShell, so this code path is what's left when @@ -1323,8 +1337,9 @@ class ShellFileOperations(FileOperations): src = self._expand_path(src) dst = self._expand_path(dst) for p in (src, dst): - if _is_write_denied(p): - return WriteResult(error=f"Move denied: {p} is a protected path") + denied = get_write_denied_error(p, verb="Move") + if denied: + return WriteResult(error=denied) result = self._exec( f"mv {self._escape_shell_arg(src)} {self._escape_shell_arg(dst)}" ) @@ -1371,8 +1386,9 @@ class ShellFileOperations(FileOperations): path = self._expand_path(path) # Block writes to sensitive paths - if _is_write_denied(path): - return WriteResult(error=f"Write denied: '{path}' is a protected system/credential file.") + denied = get_write_denied_error(path) + if denied: + return WriteResult(error=denied) # ── Fail-closed pre-write syntax gate ─────────────────────────── # Validate the CANDIDATE content BEFORE any bytes touch disk — @@ -1554,8 +1570,9 @@ class ShellFileOperations(FileOperations): path = self._expand_path(path) # Block writes to sensitive paths - if _is_write_denied(path): - return PatchResult(error=f"Write denied: '{path}' is a protected system/credential file.") + denied = get_write_denied_error(path) + if denied: + return PatchResult(error=denied) # Read current content read_cmd = f"cat {self._escape_shell_arg(path)} 2>/dev/null" diff --git a/tools/file_tools.py b/tools/file_tools.py index 61e6ecc588d5..e602e8e0a675 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -6,6 +6,7 @@ import json import logging import os import posixpath +import sys import threading from pathlib import Path, PurePosixPath @@ -406,6 +407,17 @@ def _resolve_base_dir( if not posixpath.isabs(base_text): base_text = posixpath.join(os.getcwd(), base_text) return _normalize_without_host_deref(base_text) + # Git Bash ``pwd -P`` reports ``/c/Users/...``; translate before Path so + # relative file-tool paths don't anchor under a nonexistent ``\\c\\Users``. + from tools.environments.local import _msys_to_windows_path + + base_text = _msys_to_windows_path(base_text) + if sys.platform == "win32": + import ntpath + + if not ntpath.isabs(base_text): + base_text = ntpath.join(os.getcwd(), base_text) + return Path(ntpath.normpath(base_text)) base = Path(base_text) if not base.is_absolute(): # Last-resort anchoring: a live cwd should already be absolute, but if a @@ -420,14 +432,31 @@ def _resolve_path_for_task(filepath: str, task_id: str = "default") -> Path | Pu See :func:`_resolve_base_dir` for how the base is chosen. Absolute input paths are returned resolved-but-unanchored. + + On native Windows, Git Bash / MSYS drive paths (``/c/Users/...``) are + translated to ``C:\\Users\\...`` before resolution so file tools don't + treat them as relative ``\\c\\Users\\...`` under the process cwd. """ container_paths = _uses_container_paths(task_id) - expanded = _expand_tilde(filepath) if container_paths: + expanded = _expand_tilde(filepath) if posixpath.isabs(expanded): return _normalize_without_host_deref(expanded) resolved = _resolve_base_dir(task_id, container_paths=True) / expanded return _normalize_without_host_deref(resolved) + + # Host paths only — never rewrite Linux paths inside a container/WSL env. + from tools.environments.local import _msys_to_windows_path + + expanded = _expand_tilde(_msys_to_windows_path(filepath)) + if sys.platform == "win32": + import ntpath + + if ntpath.isabs(expanded): + return Path(ntpath.normpath(expanded)) + joined = ntpath.join(str(_resolve_base_dir(task_id, container_paths=False)), expanded) + return Path(ntpath.normpath(joined)) + p = Path(expanded) if p.is_absolute(): return p.resolve() diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 7806db57efb0..ff46518bd3fc 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -1084,18 +1084,7 @@ def _build_no_backend_setup_message() -> str: def check_image_generation_requirements() -> bool: - """True if any image gen backend is available. - - Providers are considered in this order: - - 1. The in-tree FAL backend (FAL_KEY or managed gateway). - 2. Any plugin-registered provider whose ``is_available()`` returns True. - - Plugins win only when the in-tree FAL path is NOT ready, which matches - the historical behavior: shipping hermes with a FAL key configured - should still expose the tool. The active selection among ready - providers is resolved per-call by ``image_gen.provider``. - """ + """True if FAL or the explicitly configured image backend is available.""" try: if check_fal_api_key(): # Trigger the lazy fal_client import here as the SDK presence @@ -1107,22 +1096,21 @@ def check_image_generation_requirements() -> bool: except ImportError: pass - # Probe plugin providers. Discovery is idempotent and cheap. + configured = _read_configured_image_provider() + if not configured or configured == "fal": + return False + + # Probe only the explicitly selected plugin. Merely possessing a cloud + # provider key must not opt a user into a paid image-generation backend. try: - from agent.image_gen_registry import list_providers + from agent.image_gen_registry import get_provider from hermes_cli.plugins import _ensure_plugins_discovered _ensure_plugins_discovered() - for provider in list_providers(): - try: - if provider.is_available(): - return True - except Exception: - continue + provider = get_provider(configured) + return bool(provider and provider.is_available()) except Exception: - pass - - return False + return False # --------------------------------------------------------------------------- @@ -1248,7 +1236,7 @@ def _read_configured_image_model(): def _read_configured_image_provider(): - """Return the value of ``image_gen.provider`` from config.yaml, or None. + """Return ``image_gen.provider`` from config.yaml, or None. We only consult the plugin registry when this is explicitly set — an unset value keeps users on the in-tree FAL fallback even when other @@ -1293,8 +1281,8 @@ def _dispatch_to_plugin_provider( route to its edit endpoint. """ configured = _read_configured_image_provider() - if not configured: - return None + if not configured or configured == "fal": + return None # unset/explicit FAL keeps the legacy FAL path # Also read configured model so we can pass it to the plugin configured_model = _read_configured_image_model() diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 2e81f94429e0..5207c756aa6c 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -630,6 +630,13 @@ def _handle_complete(args: dict, **kw) -> str: created_cards=created_cards, expected_run_id=_worker_run_id(tid), ) + except kb.ArtifactPreservationError as artifact_err: + return tool_error( + f"kanban_complete could not preserve the declared artifacts: " + f"{artifact_err}. Your task is still in-flight and its " + f"scratch workspace was kept. Fix the artifact path or " + f"storage error, then retry kanban_complete with the same handoff." + ) except kb.HallucinatedCardsError as hall_err: # Structured rejection — surface the phantom ids so the # worker can retry with a corrected list or drop the @@ -1277,8 +1284,10 @@ KANBAN_COMPLETE_SCHEMA = { "lands with the completion notification. Skip " "intermediate scratch files and references that " "are not the deliverable. The path must exist " - "on disk when the notifier runs; missing files " - "are silently skipped." + "on disk at completion. Files inside a managed scratch " + "workspace are copied to durable task attachments before " + "cleanup; a missing declared scratch artifact keeps the " + "task in-flight so you can fix the path and retry." ), }, "board": _board_schema_prop(), diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 94cd069b8d33..06155bfe8493 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -168,8 +168,8 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { "aiohttp==3.14.1", # CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265 ), "platform.slack": ( - "slack-bolt==1.27.0", - "slack-sdk==3.40.1", + "slack-bolt==1.29.0", + "slack-sdk==3.43.0", "aiohttp==3.14.1", # CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265 ), "platform.matrix": ( @@ -188,7 +188,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { "qrcode==7.4.2", ), "platform.feishu": ( - "lark-oapi==1.5.3", + "lark-oapi==1.6.8", "qrcode==7.4.2", ), # WeCom callback-mode adapter — parses untrusted XML POST bodies. Pulls diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index cd999f2712f7..4438a715718a 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -102,6 +102,7 @@ import shutil import sys import threading import time +from types import SimpleNamespace from typing import Callable from datetime import datetime from typing import Any, Coroutine, Dict, List, Optional @@ -714,6 +715,169 @@ def _cache_mcp_image_block(block) -> str: return f"MEDIA:{image_path}" +# --------------------------------------------------------------------------- +# MCP resource blocks (ResourceLink / EmbeddedResource / AudioContent) +# --------------------------------------------------------------------------- + +# Hard cap on decoded resource bytes materialized from an MCP tool result. +# Prevents a misbehaving server from filling the cache disk via one block. +_MCP_RESOURCE_MAX_BYTES = 50 * 1024 * 1024 + +# Base64 expands raw bytes by ~4/3; reject oversized payloads before decoding +# so a multi-GB blob string is never transiently doubled in memory. +_MCP_RESOURCE_MAX_B64_CHARS = _MCP_RESOURCE_MAX_BYTES * 4 // 3 + 4 + + +def _mcp_resource_filename(uri: str, mime_type: str) -> str: + """Derive a safe display filename for an MCP resource. + + Only the last path segment of the URI is considered, and only as a + *name hint* — `cache_document_from_bytes` re-sanitizes and prefixes it, + so remote path components can't influence the cache location. + """ + import mimetypes + import re as _re + from pathlib import Path + from urllib.parse import urlparse, unquote + + name = "" + if uri: + try: + name = Path(unquote(urlparse(str(uri)).path or "")).name + except (ValueError, TypeError): + name = "" + # Strip control characters (newlines/ANSI escapes from hostile URIs would + # otherwise land in the filename and the transcript marker) and cap the + # length, preserving the extension. + name = _re.sub(r"[\x00-\x1f\x7f]", "", name).strip() + if len(name) > 150: + stem, dot, ext = name.rpartition(".") + if dot and 0 < len(ext) <= 12: + name = stem[: 150 - len(ext) - 1] + "." + ext + else: + name = name[:150] + if not name or name in {".", ".."}: + normalized = (mime_type or "").split(";", 1)[0].strip().lower() + ext = mimetypes.guess_extension(normalized) or ".bin" + name = f"resource{ext}" + return name + + +def _cache_mcp_audio_block(block) -> str: + """Cache an MCP ``AudioContent`` block and return a ``MEDIA:`` tag. + + Returns an empty string when *block* is not audio or on any failure — + same fail-open contract as ``_cache_mcp_image_block``. + """ + import base64 + + data = getattr(block, "data", None) + mime_type = str(getattr(block, "mimeType", None) or "").split(";", 1)[0].strip().lower() + if data is None or not mime_type.startswith("audio/"): + return "" + if len(data) > _MCP_RESOURCE_MAX_B64_CHARS: + return f"[MCP audio resource too large to cache: ~{len(data) * 3 // 4} bytes]" + try: + raw_bytes = base64.b64decode(data) + except (TypeError, ValueError) as exc: + logger.warning("MCP audio block decode failed (%s): %s", mime_type, exc) + return "" + if len(raw_bytes) > _MCP_RESOURCE_MAX_BYTES: + return f"[MCP audio resource too large to cache: {len(raw_bytes)} bytes]" + try: + from gateway.platforms.base import cache_audio_from_bytes + import mimetypes + + ext = ( + {"audio/wav": ".wav", "audio/x-wav": ".wav", "audio/wave": ".wav"}.get(mime_type) + or mimetypes.guess_extension(mime_type) + or ".ogg" + ) + audio_path = cache_audio_from_bytes(raw_bytes, ext=ext) + except ImportError: + logger.debug("MCP audio caching skipped — gateway.platforms.base unavailable") + return "" + except Exception as exc: + logger.warning("MCP audio block cache failed: %s", exc) + return "" + return f"MEDIA:{audio_path}" + + +def _render_mcp_resource_block(block, server_name: str = "") -> str: + """Render an MCP ``ResourceLink`` or ``EmbeddedResource`` block as text. + + - ``EmbeddedResource`` with text contents → the text itself. + - ``EmbeddedResource`` with blob contents → bytes are decoded (size-capped) + and materialized into the Hermes document cache; returns a marker with + the local path so file/terminal tools can consume it. + - ``ResourceLink`` → the URI plus a pointer at the server's read_resource + tool. No network fetch happens here; the link is only readable through + the originating MCP session. + + Returns an empty string for non-resource blocks. Failures are logged and + reported inline rather than silently dropping the block. + """ + block_type = getattr(block, "type", "") + + if block_type == "resource_link" or ( + hasattr(block, "uri") and not hasattr(block, "resource") and block_type != "text" + ): + uri = getattr(block, "uri", None) + if not uri: + return "" + name = getattr(block, "name", "") or "" + mime = getattr(block, "mimeType", "") or "" + details = f"uri={uri}" + if name: + details += f", name={name}" + if mime: + details += f", mimeType={mime}" + reader = ( + mcp_prefixed_tool_name(server_name, "read_resource") + if server_name + else "the MCP server's read_resource tool" + ) + return f"[MCP resource link: {details} — fetch it with {reader}]" + + resource = getattr(block, "resource", None) + if resource is None: + return "" + + text = getattr(resource, "text", None) + if text is not None: + return str(text) + + blob = getattr(resource, "blob", None) + if blob is None: + return "" + + import base64 + + uri = str(getattr(resource, "uri", "") or "") + mime = str(getattr(resource, "mimeType", "") or "") + if len(blob) > _MCP_RESOURCE_MAX_B64_CHARS: + return f"[MCP embedded resource too large to cache: ~{len(blob) * 3 // 4} bytes, uri={uri}]" + try: + raw_bytes = base64.b64decode(blob) + except (TypeError, ValueError) as exc: + logger.warning("MCP embedded resource decode failed (%s): %s", mime or uri, exc) + return f"[MCP embedded resource could not be decoded: {mime or uri}]" + if len(raw_bytes) > _MCP_RESOURCE_MAX_BYTES: + return f"[MCP embedded resource too large to cache: {len(raw_bytes)} bytes, uri={uri}]" + try: + from gateway.platforms.base import cache_document_from_bytes + + path = cache_document_from_bytes(raw_bytes, _mcp_resource_filename(uri, mime)) + except ImportError: + logger.debug("MCP resource caching skipped — gateway.platforms.base unavailable") + return f"[MCP embedded resource received ({len(raw_bytes)} bytes, {mime or 'unknown type'}) but document cache unavailable in this process]" + except Exception as exc: + logger.warning("MCP embedded resource cache failed: %s", exc) + return f"[MCP embedded resource could not be cached: {mime or uri}]" + detail = mime or "unknown type" + return f"[MCP resource saved to {path} ({detail}, {len(raw_bytes)} bytes) — read it with read_file or terminal tools]" + + # --------------------------------------------------------------------------- # Remote MCP URL validation # --------------------------------------------------------------------------- @@ -3947,8 +4111,15 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): if result.isError: error_text = "" for block in (result.content or []): - if hasattr(block, "text"): + if getattr(block, "text", None): error_text += block.text + continue + # EmbeddedResource blocks inside error payloads carry + # their text under .resource.text — previously dropped, + # leaving a bare "MCP tool returned an error". + res_text = getattr(getattr(block, "resource", None), "text", None) + if res_text: + error_text += str(res_text) return json.dumps({ "error": _sanitize_error( error_text or "MCP tool returned an error" @@ -3974,6 +4145,34 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): image_tag = _cache_mcp_image_block(block) if image_tag: parts.append(image_tag) + continue + audio_tag = _cache_mcp_audio_block(block) + if audio_tag: + parts.append(audio_tag) + continue + # ResourceLink / EmbeddedResource blocks (PDFs, archives, + # office docs, ...). Previously these were silently dropped, + # so document-oriented MCP tools appeared to return metadata + # only (enterprise customer report, 2026-07). + resource_text = _render_mcp_resource_block(block, server_name) + if resource_text: + parts.append(resource_text) + continue + # Benign empty renders (empty text blocks, empty text + # resources, audio in a process without the gateway cache) + # aren't data loss — log at debug. Warn only for genuinely + # unrecognized block shapes. + block_type = getattr(block, "type", None) or type(block).__name__ + if block_type in {"text", "resource", "audio", "image"}: + logger.debug( + "MCP %s: content block type %r rendered empty", + server_name, block_type, + ) + else: + logger.warning( + "MCP %s: dropping unsupported content block type %r", + server_name, block_type, + ) text_result = "\n".join(parts) if parts else "" # Combine content + structuredContent when both are present. @@ -4124,10 +4323,17 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float): parts: List[str] = [] contents = result.contents if hasattr(result, "contents") else [] for block in contents: - if hasattr(block, "text"): + if getattr(block, "text", None) is not None: parts.append(block.text) - elif hasattr(block, "blob"): - parts.append(f"[binary data, {len(block.blob)} bytes]") + elif getattr(block, "blob", None) is not None: + # Materialize binary resource contents into the document + # cache instead of discarding them (same contract as + # EmbeddedResource blocks in tool results). + rendered = _render_mcp_resource_block( + SimpleNamespace(type="resource", resource=block), + server_name, + ) + parts.append(rendered or f"[binary data, {len(block.blob)} bytes]") return json.dumps({"result": "\n".join(parts) if parts else ""}, ensure_ascii=False) def _call_once(): diff --git a/tools/patch_parser.py b/tools/patch_parser.py index e16cb446ee03..c02037f77cae 100644 --- a/tools/patch_parser.py +++ b/tools/patch_parser.py @@ -253,8 +253,11 @@ def _validate_operations( from tools.fuzzy_match import fuzzy_find_and_replace errors: List[str] = [] + real_change_count = 0 for op in operations: + if op.operation != OperationType.UPDATE: + real_change_count += 1 if op.operation == OperationType.UPDATE: read_result = file_ops.read_file_raw(op.file_path) if read_result.error: @@ -262,8 +265,15 @@ def _validate_operations( continue simulated = read_result.content - for hunk in op.hunks: + for hunk_index, hunk in enumerate(op.hunks, start=1): search_lines = [l.content for l in hunk.lines if l.prefix in {' ', '-'}] + removed_lines = [l.content for l in hunk.lines if l.prefix == '-'] + added_lines = [l.content for l in hunk.lines if l.prefix == '+'] + if not removed_lines and not added_lines: + # Models occasionally emit inert anchor hunks between real + # changes. Ignore them without poisoning the atomic patch. + continue + real_change_count += 1 if not search_lines: # Addition-only hunk: validate context hint uniqueness if hunk.context_hint: @@ -291,7 +301,7 @@ def _validate_operations( if count == 0: label = f"'{hunk.context_hint}'" if hunk.context_hint else "(no hint)" msg = ( - f"{op.file_path}: hunk {label} not found" + f"{op.file_path}: hunk {hunk_index} {label} not found" + (f" — {match_error}" if match_error else "") ) try: @@ -325,6 +335,9 @@ def _validate_operations( # ADD: parent directory creation handled by write_file; no pre-check needed. + if not errors and real_change_count == 0: + errors.append("Patch contains no changes (only context lines were provided)") + return errors @@ -545,6 +558,8 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str, Optiona elif line.prefix == '+': replace_lines.append(line.content) + if search_lines and search_lines == replace_lines: + continue if search_lines: search_pattern = '\n'.join(search_lines) replacement = '\n'.join(replace_lines) diff --git a/tools/process_registry.py b/tools/process_registry.py index 0faa80ba6e22..62dd8494e4ae 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -171,6 +171,13 @@ class ProcessRegistry: # gateway drain this after each agent turn to auto-trigger new turns. import queue as _queue_mod self.completion_queue: _queue_mod.Queue = _queue_mod.Queue() + # Rehydrate durable delegation completions only at registry startup. + # Consumers still inject them as fresh turns through this existing rail. + try: + from tools.async_delegation import restore_undelivered_completions + restore_undelivered_completions(self.completion_queue) + except Exception as exc: + logger.warning("Could not restore async delegation completions: %s", exc) # Track sessions whose completion was already consumed by the agent # via wait/log. Drain loops AND gateway/tui watchers skip notifications @@ -1091,6 +1098,10 @@ class ProcessRegistry: "completion_reason": session.completion_reason, "termination_source": session.termination_source, "output": output_tail, + # Stable producer identity across checkpoint recovery; unlike + # a consumer-observed completion timestamp, this does not vary + # based on which watcher notices exit first. + "started_at": session.started_at, }) # ----- Query Methods ----- @@ -1200,6 +1211,19 @@ class ProcessRegistry: if evt_session_key != session_key: requeue.append(evt) continue + elif evt.get("restored"): + # Legacy unfiltered drain (no ownership callback, no + # session key). That behavior was safe when the in-memory + # queue could only hold events created by this very + # process — but durable restore (#63494) re-enqueues + # completions from PREVIOUS processes at startup, so an + # unfiltered consumer here would adopt a dead, unrelated + # session's conversation payload (#64484). Fail closed: + # leave restored events queued (still 'pending' on disk) + # for a consumer that can positively prove ownership, + # e.g. the owning session's --resume. + requeue.append(evt) + continue text = format_process_notification(evt) if text: results.append((evt, text)) @@ -1347,8 +1371,13 @@ class ProcessRegistry: # Default: last N lines if offset == 0 and limit > 0: selected = lines[-limit:] + observed_completion_output = bool(selected) or total_lines == 0 else: selected = lines[offset:offset + limit] + stop = slice(offset, offset + limit).indices(total_lines)[1] + observed_completion_output = ( + total_lines == 0 or (bool(selected) and stop == total_lines) + ) result = { "session_id": session.id, @@ -1358,7 +1387,7 @@ class ProcessRegistry: "total_lines": total_lines, "showing": f"{len(selected)} lines", } - if session.exited: + if session.exited and observed_completion_output: self._completion_consumed.add(session_id) return result @@ -1449,17 +1478,41 @@ class ProcessRegistry: result["timeout_note"] = f"Waited {effective_timeout}s, process still running" return result - def kill_process(self, session_id: str, *, source: str = "process.kill") -> dict: - """Kill a background process.""" + def kill_process( + self, + session_id: str, + *, + source: str = "process.kill", + consume_output: bool = True, + ) -> dict: + """Kill a background process and return its output snapshot. + + ``consume_output`` is true for explicit tool/RPC kills because their + caller observes the returned output. Bulk cleanup passes false: it + discards each result and therefore must not suppress an autonomous + output-bearing completion notification. + """ + from tools.ansi_strip import strip_ansi + session = self.get(session_id) if session is None: return {"status": "not_found", "error": f"No process with ID {session_id}"} if session.exited: - return { - "status": "already_exited", - "exit_code": session.exit_code, - } + with session._lock: + result = { + "status": "already_exited", + "command": session.command, + "exit_code": session.exit_code, + "completion_reason": session.completion_reason, + "termination_source": session.termination_source, + "output": strip_ansi(session.output_buffer[-2000:]), + } + # Only suppress the autonomous turn after its output is present in + # the explicit kill result, matching wait/log consumption. + if consume_output: + self._completion_consumed.add(session_id) + return result # Kill via PTY, Popen (local), or env execute (non-local) try: @@ -1486,10 +1539,14 @@ class ProcessRegistry: with session._lock: session.exited = True session.exit_code = None + output = strip_ansi(session.output_buffer[-2000:]) + if consume_output: + self._completion_consumed.add(session_id) self._move_to_finished(session) return { "status": "already_exited", "exit_code": session.exit_code, + "output": output, } self._terminate_host_pid(session.pid, session.host_start_time) else: @@ -1500,10 +1557,17 @@ class ProcessRegistry: "its original runtime handle is no longer available" ), } - session.exited = True - session.exit_code = -15 # SIGTERM - session.completion_reason = "killed" - session.termination_source = source + # Capture output before marking consumed, then mark consumed before + # exposing ``exited`` to watcher tasks. This closes the delayed + # notification race without discarding the terminal transcript. + with session._lock: + output = strip_ansi(session.output_buffer[-2000:]) + if consume_output: + self._completion_consumed.add(session_id) + session.exited = True + session.exit_code = -15 # SIGTERM + session.completion_reason = "killed" + session.termination_source = source self._move_to_finished(session) self._write_checkpoint() return { @@ -1511,6 +1575,7 @@ class ProcessRegistry: "session_id": session.id, "completion_reason": session.completion_reason, "termination_source": session.termination_source, + "output": output, } except Exception as e: return {"status": "error", "error": str(e)} @@ -1747,7 +1812,11 @@ class ProcessRegistry: killed = 0 for session in targets: - result = self.kill_process(session.id, source="kill_all") + result = self.kill_process( + session.id, + source="kill_all", + consume_output=False, + ) if result.get("status") in {"killed", "already_exited"}: killed += 1 return killed @@ -2238,7 +2307,10 @@ def _handle_process(args, **kw): elif action == "wait": return json.dumps(_redact_process_result(process_registry.wait(session_id, timeout=args.get("timeout"))), ensure_ascii=False) elif action == "kill": - return json.dumps(process_registry.kill_process(session_id), ensure_ascii=False) + return json.dumps( + _redact_process_result(process_registry.kill_process(session_id)), + ensure_ascii=False, + ) elif action == "write": return json.dumps(process_registry.write_stdin(session_id, str(args.get("data", ""))), ensure_ascii=False) elif action == "submit": diff --git a/tools/registry.py b/tools/registry.py index 35589bd2c849..354da7123fd7 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -45,11 +45,20 @@ def _module_registers_tools(module_path: Path) -> bool: Only inspects module-body statements so that helper modules which happen to call ``registry.register()`` inside a function are not picked up. + + A cheap text prefilter avoids the ``ast.parse`` cost for files that do not + mention both ``registry`` and ``register`` — a necessary condition for a + top-level ``registry.register()`` call to exist. """ try: source = module_path.read_text(encoding="utf-8") + except OSError: + return False + if "registry" not in source or "register" not in source: + return False + try: tree = ast.parse(source, filename=str(module_path)) - except (OSError, SyntaxError): + except SyntaxError: return False return any(_is_registry_register_call(stmt) for stmt in tree.body) @@ -571,10 +580,43 @@ class ToolRegistry: # Dispatch # ------------------------------------------------------------------ - def dispatch(self, name: str, args: dict, **kwargs) -> str: + @staticmethod + def _normalize_handler_result(name: str, result): + """Enforce the result shapes supported by the agent tool pipeline. + + Normal tool results are strings. The sole structured exception is the + multimodal envelope consumed by the agent executor. Returning every + other value as a string error keeps logging, hooks, budgeting, and + persistence from receiving values they cannot safely slice or size. + """ + if isinstance(result, str): + return result + if ( + isinstance(result, dict) + and result.get("_multimodal") is True + and isinstance(result.get("content"), list) + ): + return result + + result_type = type(result).__name__ + logger.error( + "Tool %s handler returned unsupported result type: %s", + name, + result_type, + ) + return json.dumps({ + "error": f"Tool handler returned unsupported result type: {result_type}", + "error_type": "tool_result_contract", + "tool": name, + "result_type": result_type, + }, ensure_ascii=False) + + def dispatch(self, name: str, args: dict, **kwargs) -> str | dict: """Execute a tool handler by name. * Async handlers are bridged automatically via ``_run_async()``. + * Handler results are normalized to a string or supported multimodal + envelope before leaving the registry. * All exceptions are caught and returned as ``{"error": "..."}`` for consistent error format. """ @@ -584,8 +626,10 @@ class ToolRegistry: try: if entry.is_async: from model_tools import _run_async - return _run_async(entry.handler(args, **kwargs)) - return entry.handler(args, **kwargs) + result = _run_async(entry.handler(args, **kwargs)) + else: + result = entry.handler(args, **kwargs) + return self._normalize_handler_result(name, result) except Exception as e: logger.exception("Tool %s dispatch error: %s", name, e) # Route through the sanitizer so framing tokens / CDATA / fences diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 5b0714b0ba1f..c143ea8064f1 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -65,6 +65,62 @@ _VOICE_EXTS = {".ogg", ".opus"} # formats either route through sendVoice (Opus/OGG) or fall back to # document delivery. _TELEGRAM_SEND_AUDIO_EXTS = {".mp3", ".m4a"} + +# Extensions that carry a native caption on the media bubble itself +# (photo/video/document). Voice/audio notes are excluded: a caption on a +# voice note reads as a separate label rather than a bubble caption, and the +# established convention is to keep the accompanying text as its own message. +_CAPTIONABLE_EXTS = _IMAGE_EXTS | _VIDEO_EXTS | { + ".pdf", ".doc", ".docx", ".txt", ".md", ".csv", ".xlsx", ".zip", +} + +# Per-platform native caption length limits (characters). Text longer than +# the limit can't ride on the media bubble and stays a separate body message. +# Telegram's photo/video caption cap is 1024; WhatsApp and Discord are far +# more generous, so a conservative shared ceiling keeps behavior predictable. +_TELEGRAM_CAPTION_LIMIT = 1024 +_DEFAULT_CAPTION_LIMIT = 4096 + + +def _media_caption_split(text, media_files, *, max_caption_len): + """Decide whether the accompanying text should ride on the media bubble. + + Single enforced chokepoint for the ``MEDIA: caption`` behavior + across every standalone sender. ``hermes send`` (and the send_message + tool / cron) strips the ``MEDIA:`` tag and leaves the remaining prose as + ``text``; historically each platform sent that ``text`` as a *separate* + message before an uncaptioned media bubble, splitting the reported case + ``hermes send --to whatsapp "MEDIA:/x.png This Caption"`` into two parts. + + Returns ``(caption, body_text)``: + + * ``(caption, "")`` — attach ``text`` to the media as its native caption + and send *no* separate body message. Only when there is exactly one + media file, it is a captionable kind (image/video/document, not a + voice/audio note), and ``text`` fits ``max_caption_len``. + * ``(None, text)`` — keep the historical behavior: ``text`` is a separate + body message and the media carries no caption. Applies to multi-file + sends (caption→file association is ambiguous), voice/audio notes, empty + text, or text longer than the caption limit. + """ + stripped = (text or "").strip() + media = media_files or [] + if not stripped or len(media) != 1: + return None, text + media_path, is_voice = media[0] + if is_voice: + return None, text + ext = os.path.splitext(media_path)[1].lower() + if ext not in _CAPTIONABLE_EXTS: + return None, text + # Measure the caption in Unicode codepoints — a portable upper bound that + # never under-counts vs Telegram's UTF-16 units for BMP text, so an + # over-count only fails safe (falls back to a separate message). The + # Telegram call site additionally re-checks the *formatted* caption in + # UTF-16 units, since MarkdownV2/HTML escaping can inflate the length. + if len(stripped) > max_caption_len: + return None, text + return stripped, "" _URL_SECRET_QUERY_RE = re.compile( r"([?&](?:access_token|api[_-]?key|auth[_-]?token|token|signature|sig)=)([^&#\s]+)", re.IGNORECASE, @@ -814,6 +870,26 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, entry = platform_registry.get("discord") if entry is None or entry.standalone_sender_fn is None: return {"error": "Discord plugin not registered or missing standalone_sender_fn"} + # MEDIA: caption: single captionable file + short text rides as + # the media message content instead of a separate message before the + # attachment (single enforced decision in _media_caption_split). Cap on + # the platform's own message limit so the caption is always deliverable. + _dc_caption, _ = _media_caption_split( + message, media_files, + max_caption_len=(max_len or _DEFAULT_CAPTION_LIMIT), + ) + if _dc_caption is not None: + result = await entry.standalone_sender_fn( + pconfig, + chat_id, + "", + thread_id=thread_id, + media_files=media_files, + caption=_dc_caption, + ) + if isinstance(result, dict) and result.get("error"): + return result + return result last_result = None for i, chunk in enumerate(chunks): is_last = (i == len(chunks) - 1) @@ -916,7 +992,30 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, _wa_entry = _pr_wa.get("whatsapp") if _wa_entry is None or _wa_entry.standalone_sender_fn is None: return {"error": "WhatsApp plugin not registered or missing standalone_sender_fn"} + # MEDIA: caption: a single captionable file + short text rides + # as the media's native caption instead of a separate message before + # the bubble (single enforced decision in _media_caption_split). Cap on + # the platform's own message limit so the caption is always deliverable. + _wa_caption, _ = _media_caption_split( + message, media_files, + max_caption_len=(max_len or _DEFAULT_CAPTION_LIMIT), + ) last_result = None + if _wa_caption is not None: + # Single-file captioned send: no separate text chunk, caption on + # the media itself. + result = await _wa_entry.standalone_sender_fn( + pconfig, + chat_id, + "", + media_files=media_files, + thread_id=thread_id, + force_document=force_document, + caption=_wa_caption, + ) + if isinstance(result, dict) and result.get("error"): + return result + return result for i, chunk in enumerate(chunks): is_last = (i == len(chunks) - 1) result = await _wa_entry.standalone_sender_fn( @@ -1109,6 +1208,23 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No last_msg = None warnings = [] + # MEDIA: caption: when a single captionable file is accompanied + # by short text, attach the text to the media bubble as its native + # caption instead of sending it as a separate message beforehand + # (single enforced decision in _media_caption_split). Caption with the + # *formatted* text so MarkdownV2/HTML styling is preserved, but guard + # the formatted length against Telegram's 1024 cap — formatting can + # inflate a raw-<1024 string past it, in which case fall back to a + # separate body message. + _tg_caption = None + from gateway.platforms.base import utf16_len as _utf16_len + _cap, _ = _media_caption_split( + message, media_files, max_caption_len=_TELEGRAM_CAPTION_LIMIT + ) + if _cap is not None and _utf16_len(formatted) <= _TELEGRAM_CAPTION_LIMIT: + _tg_caption = formatted + formatted = "" # suppress the separate text send below + if formatted.strip(): # Chunk *after* formatting: MarkdownV2/HTML escaping inflates the # text (each escaped char like `!`/`.`/`-` becomes `\!`/`\.`/`\-`), @@ -1170,12 +1286,34 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No warning = f"Media file not found, skipping: {media_path}" logger.warning(warning) warnings.append(warning) + # Caption mode suppressed the separate text send; if the file + # it was meant to caption is gone, deliver the caption text on + # its own so the words aren't silently lost. + if _tg_caption is not None and last_msg is None: + try: + last_msg = await _send_telegram_message_with_retry( + bot, chat_id=int_chat_id, text=_tg_caption, + parse_mode=send_parse_mode, **text_kwargs + ) + _tg_caption = None # delivered — don't re-caption a later file + except Exception as _cap_err: + logger.warning( + "Telegram caption-fallback send failed for missing media: %s", + _sanitize_error_text(_cap_err), + ) continue ext = os.path.splitext(media_path)[1].lower() try: with open(media_path, "rb") as f: media_kwargs = dict(thread_kwargs) + # Attach the MEDIA: caption to the bubble itself for + # captionable kinds (photo/video/document). _tg_caption is + # only set for a single captionable file, so this never + # double-captions a multi-file send or a voice note. + if _tg_caption is not None and not (ext in _VOICE_EXTS and is_voice): + media_kwargs["caption"] = _tg_caption + media_kwargs["parse_mode"] = send_parse_mode try: if ext in _IMAGE_EXTS and not force_document: last_msg = await bot.send_photo( @@ -1228,6 +1366,37 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No last_msg = await bot.send_document( chat_id=int_chat_id, document=f, **media_kwargs ) + elif media_kwargs.get("parse_mode") and ( + "parse" in str(media_err).lower() + or "caption" in str(media_err).lower() + ): + # Caption failed to parse as MarkdownV2/HTML — + # retry with a plain-text caption so the media + # (and its caption) still deliver. + logger.warning( + "Caption parse failed for media send, retrying plain: %s", + _sanitize_error_text(media_err), + ) + f.seek(0) + media_kwargs.pop("parse_mode", None) + if not _has_html and media_kwargs.get("caption"): + try: + from plugins.platforms.telegram.adapter import _strip_mdv2 + media_kwargs["caption"] = _strip_mdv2(media_kwargs["caption"]) + except Exception: + pass + if ext in _IMAGE_EXTS and not force_document: + last_msg = await bot.send_photo( + chat_id=int_chat_id, photo=f, **media_kwargs + ) + elif ext in _VIDEO_EXTS: + last_msg = await bot.send_video( + chat_id=int_chat_id, video=f, **media_kwargs + ) + else: + last_msg = await bot.send_document( + chat_id=int_chat_id, document=f, **media_kwargs + ) else: raise except Exception as e: diff --git a/tools/skills_guard.py b/tools/skills_guard.py index a1a8606d6d47..47f200ab880b 100644 --- a/tools/skills_guard.py +++ b/tools/skills_guard.py @@ -25,12 +25,16 @@ Usage: import re import fnmatch import hashlib +import json from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import List, Tuple +SCANNER_VERSION = "skills-guard-v1" + + # --------------------------------------------------------------------------- @@ -87,6 +91,7 @@ class ScanResult: findings: List[Finding] = field(default_factory=list) scanned_at: str = "" summary: str = "" + scan_provenance: dict = field(default_factory=dict) # --------------------------------------------------------------------------- @@ -683,6 +688,81 @@ def scan_skill(skill_path: Path, source: str = "community") -> ScanResult: ) +def _content_digest(skill_path: Path) -> str: + """Canonical SHA-256 over relative paths and exact file bytes.""" + h = hashlib.sha256() + if skill_path.is_dir(): + for file_path in sorted(skill_path.rglob("*")): + if file_path.is_file(): + rel = file_path.relative_to(skill_path).as_posix() + h.update(rel.encode("utf-8") + b"\x00") + h.update(file_path.read_bytes()) + else: + h.update(skill_path.read_bytes()) + return h.hexdigest() + + +def full_content_hash(skill_path: Path) -> str: + """Full canonical digest used to bind scanner attestations.""" + return f"sha256:{_content_digest(skill_path)}" + + +def _finding_dict(finding: Finding) -> dict: + return {key: getattr(finding, key) for key in ( + "pattern_id", "severity", "category", "file", "line", "match", "description" + )} + + +def scan_skill_cached( + skill_path: Path, + source: str = "community", + *, + source_url: str = "", + cache_dir: Path | None = None, +) -> Tuple[ScanResult, dict]: + """Return a scan plus attestation, caching only exact current content.""" + bundle_hash = full_content_hash(skill_path) + cache_root = cache_dir or skill_path.parent / ".scan-cache" + source_identity = hashlib.sha256(f"{source}\0{source_url}".encode("utf-8")).hexdigest()[:16] + cache_file = cache_root / f"{bundle_hash.split(':', 1)[1]}-{source_identity}.json" + try: + cached = json.loads(cache_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + cached = None + if (isinstance(cached, dict) + and cached.get("bundle_hash") == bundle_hash + and cached.get("scanner_version") == SCANNER_VERSION + and cached.get("source") == source + and cached.get("source_url") == source_url): + result = ScanResult( + skill_name=skill_path.name, source=source, + trust_level=cached["trust_level"], verdict=cached["verdict"], + findings=[Finding(**item) for item in cached.get("findings", [])], + scanned_at=cached["scanned_at"], summary=cached.get("summary", ""), + ) + provenance = dict(cached) + provenance["fresh"] = False + result.scan_provenance = provenance + return result, provenance + + result = scan_skill(skill_path, source=source) + findings = [_finding_dict(item) for item in result.findings] + provenance = { + "source": source, "source_url": source_url, "bundle_hash": bundle_hash, + "scanner_version": SCANNER_VERSION, "verdict": result.verdict, + "trust_level": result.trust_level, "findings": findings, + "rules": sorted({item["pattern_id"] for item in findings}), + "scanned_at": result.scanned_at, "summary": result.summary, "fresh": True, + } + try: + cache_root.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8") + except OSError: + pass + result.scan_provenance = provenance + return result, provenance + + def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool, str]: """ Determine whether a skill should be installed based on scan result and trust. @@ -774,20 +854,7 @@ def content_hash(skill_path: Path) -> str: one on an in-memory bundle), so any change to the hash shape MUST land in both places at once. """ - h = hashlib.sha256() - if skill_path.is_dir(): - for f in sorted(skill_path.rglob("*")): - if f.is_file(): - try: - rel = f.relative_to(skill_path).as_posix() - h.update(rel.encode("utf-8")) - h.update(b"\x00") - h.update(f.read_bytes()) - except OSError: - continue - elif skill_path.is_file(): - h.update(skill_path.read_bytes()) - return f"sha256:{h.hexdigest()[:16]}" + return f"sha256:{_content_digest(skill_path)[:16]}" # --------------------------------------------------------------------------- diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 9883b720ee02..755b32fcbbe2 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -29,7 +29,7 @@ from hermes_constants import get_hermes_home from hermes_cli._subprocess_compat import windows_hide_flags from agent.skill_utils import is_excluded_skill_path from typing import Any, Dict, List, Optional, Tuple, Union -from urllib.parse import urljoin, urlparse, urlunparse +from urllib.parse import unquote, urljoin, urlparse, urlsplit, urlunparse import httpx import yaml @@ -152,6 +152,46 @@ class SkillBundle: metadata: Dict[str, Any] = field(default_factory=dict) +_ALLOWED_SUPPORT_DIRS = frozenset({"references", "templates", "scripts", "assets", "examples"}) +_LOCAL_LINK_RE = re.compile( + r"(?:\]\(|`|(?:^|[\s\"']))((?:references|templates|scripts|assets|examples)/[^\s)`\"'<>]+)", + re.MULTILINE, +) +_SUSPICIOUS_LOCAL_REF_RE = re.compile( + r"(?:references|templates|scripts|assets|examples)/(?:[^\s)`\"'<>]*/)?\.\.(?:/|$)" +) + + +def _referenced_support_paths(skill_md: str) -> Optional[set[str]]: + """Extract safe referenced paths; return None on a traversal attempt.""" + normalized = skill_md.replace("\\", "/") + if _SUSPICIOUS_LOCAL_REF_RE.search(normalized): + return None + paths: set[str] = set() + for match in _LOCAL_LINK_RE.finditer(normalized): + raw = unquote(urlsplit(match.group(1).rstrip(".,;:")).path) + try: + safe = _validate_bundle_rel_path(raw) + except ValueError: + return None + if safe.split("/", 1)[0] in _ALLOWED_SUPPORT_DIRS: + paths.add(safe) + return paths + + +def source_url_for_bundle(bundle: SkillBundle) -> str: + """Best available human-facing immutable-source provenance URL.""" + explicit = bundle.metadata.get("source_url") or bundle.metadata.get("url") + if explicit: + return str(explicit) + if bundle.source == "github": + parts = bundle.identifier.split("/", 2) + if len(parts) >= 2: + suffix = f"/tree/main/{parts[2]}" if len(parts) == 3 else "" + return f"https://github.com/{parts[0]}/{parts[1]}{suffix}" + return bundle.identifier + + def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bool) -> str: """Normalize and validate bundle-controlled paths before touching disk.""" if not isinstance(path_value, str): @@ -535,6 +575,7 @@ class GitHubSource(SkillSource): # Per-instance cache: repo -> (default_branch, tree_entries) # Survives within a single search/install flow, avoiding redundant API calls. self._tree_cache: Dict[str, Tuple[str, List[dict]]] = {} + self._tree_revisions: Dict[str, str] = {} # Per-repo cache of the optional skills.sh.json grouping sidecar, # mapping skill_name -> human-readable grouping title. ``None`` means # "fetched, no sidecar"; a missing key means "not fetched yet". @@ -601,9 +642,40 @@ class GitHubSource(SkillSource): repo = f"{parts[0]}/{parts[1]}" skill_path = parts[2] - files = self._download_directory(repo, skill_path) - if not files or "SKILL.md" not in files: + skill_md = self._fetch_file_content(repo, f"{skill_path.rstrip('/')}/SKILL.md") + if skill_md is None: return None + referenced = _referenced_support_paths(skill_md) + if referenced is None: + return None + + files: Dict[str, Union[str, bytes]] = {"SKILL.md": skill_md} + tree = self._get_repo_tree(repo) + if tree is not None: + branch, entries = tree + prefix = f"{skill_path.rstrip('/')}/" + entries_by_path = {item.get("path", ""): item for item in entries} + for rel_path in sorted(referenced): + item_path = f"{prefix}{rel_path}" + item = entries_by_path.get(item_path) + if item is None: + logger.warning("Referenced skill support file is missing: %s", item_path) + return None + if item.get("type") != "blob" or item.get("mode") == "120000": + logger.warning("Rejected non-regular file in skill bundle: %s", item_path) + return None + content = self._fetch_file_bytes(repo, item_path) + if content is None: + return None + files[rel_path] = content + revision = self._tree_revisions.get(repo) or branch + else: + for rel_path in referenced: + content = self._fetch_file_bytes(repo, f"{skill_path.rstrip('/')}/{rel_path}") + if content is None: + return None + files[rel_path] = content + revision = "" skill_name = skill_path.rstrip("/").split("/")[-1] trust = self.trust_level_for(identifier) @@ -614,6 +686,13 @@ class GitHubSource(SkillSource): source="github", identifier=identifier, trust_level=trust, + metadata={ + "source_url": ( + f"https://github.com/{repo}/tree/{revision}/{skill_path}" + if revision else f"https://github.com/{repo}/{skill_path}" + ), + "source_revision": revision, + }, ) def inspect(self, identifier: str) -> Optional[SkillMeta]: @@ -752,6 +831,9 @@ class GitHubSource(SkillSource): return None entries = tree_data.get("tree", []) + revision = tree_data.get("sha") + if isinstance(revision, str) and revision: + self._tree_revisions[repo] = revision self._tree_cache[repo] = (default_branch, entries) return (default_branch, entries) @@ -968,14 +1050,24 @@ class GitHubSource(SkillSource): return None def _fetch_file_content(self, repo: str, path: str) -> Optional[str]: - """Fetch a single file's content from GitHub.""" + """Fetch a single text file from GitHub.""" + content = self._fetch_file_bytes(repo, path) + if content is None: + return None + try: + return content.decode("utf-8") + except UnicodeDecodeError: + return None + + def _fetch_file_bytes(self, repo: str, path: str) -> Optional[bytes]: + """Fetch exact file bytes from GitHub without text decoding.""" url = f"https://api.github.com/repos/{repo}/contents/{path}" resp = self._github_get( url, headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"}, ) if resp is not None and resp.status_code == 200: - return resp.text + return resp.content return None def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]: @@ -1318,12 +1410,12 @@ class WellKnownSkillSource(SkillSource): # --------------------------------------------------------------------------- class UrlSource(SkillSource): - """Fetch a single-file SKILL.md skill directly from an HTTP(S) URL. + """Fetch SKILL.md plus explicitly referenced, allowlisted support files. The identifier IS the URL (e.g. ``https://example.com/path/SKILL.md``). - Only single-file skills are supported — multi-file skills with - ``references/`` or ``scripts/`` subfolders need a manifest we can't - discover from a bare URL. + Bare URLs cannot safely enumerate a repository, so only exact references + below references/templates/scripts/assets are fetched. Other repository + files are never copied. The skill name is read from the ``name:`` field in the SKILL.md YAML frontmatter (with a URL-slug fallback). Trust level is always @@ -1402,6 +1494,19 @@ class UrlSource(SkillSource): fm = GitHubSource._parse_frontmatter_quick(text) name = self._resolve_skill_name(fm, url) + referenced = _referenced_support_paths(text) + if referenced is None: + return None + files: Dict[str, Union[str, bytes]] = {"SKILL.md": text} + base_url = url.rsplit("/", 1)[0] + "/" + for rel_path in sorted(referenced): + support_url = urljoin(base_url, rel_path) + if urlparse(support_url).netloc != urlparse(url).netloc: + return None + content = self._fetch_bytes(support_url) + if content is None: + return None + files[rel_path] = content # When auto-resolution fails, return a bundle with an empty name and # ``awaiting_name=True`` in metadata. The install flow (``do_install``) @@ -1418,11 +1523,11 @@ class UrlSource(SkillSource): return SkillBundle( name=skill_name, - files={"SKILL.md": text}, + files=files, source="url", identifier=url, trust_level="community", - metadata={"url": url, "awaiting_name": not skill_name}, + metadata={"url": url, "source_url": url, "awaiting_name": not skill_name}, ) @staticmethod @@ -1432,6 +1537,13 @@ class UrlSource(SkillSource): return resp.text return None + @staticmethod + def _fetch_bytes(url: str) -> Optional[bytes]: + resp = _guarded_http_get(url, timeout=20) + if resp is not None and resp.status_code == 200: + return resp.content + return None + # Skill names must look like identifiers: lowercase letters/digits with # optional hyphens/underscores. Blocks dangerous (``../evil``) AND useless # (``SKILL``, ``README``, empty) candidates before they hit the disk. @@ -3304,6 +3416,7 @@ class HubLockFile: install_path: str, files: List[str], metadata: Optional[Dict[str, Any]] = None, + scan_provenance: Optional[Dict[str, Any]] = None, ) -> None: # Validate both the skill name and the install path SHAPE before # writing into lock.json. A poisoned lock entry is the precondition @@ -3321,6 +3434,7 @@ class HubLockFile: "install_path": safe_install_path, "files": files, "metadata": metadata or {}, + "scan_provenance": scan_provenance or {}, "installed_at": datetime.now(timezone.utc).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(), } @@ -3461,6 +3575,7 @@ def install_from_quarantine( category: str, bundle: SkillBundle, scan_result: ScanResult, + scan_provenance: Optional[Dict[str, Any]] = None, ) -> Path: """Move a scanned skill from quarantine into the skills directory.""" safe_skill_name = _validate_skill_name(skill_name) @@ -3529,6 +3644,7 @@ def install_from_quarantine( install_path=str(install_dir.relative_to(_skills_dir())), files=list(bundle.files.keys()), metadata=bundle.metadata, + scan_provenance=scan_provenance or getattr(scan_result, "scan_provenance", None), ) append_audit_log( diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 86a8a5de6cb7..a5613f62c4c8 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -68,6 +68,7 @@ Usage: import json import logging +import time from hermes_constants import get_hermes_home, display_hermes_home import os @@ -86,6 +87,54 @@ from agent.skill_utils import ( logger = logging.getLogger(__name__) +# Per-session skill discovery cache. _find_all_skills() re-reads every +# SKILL.md on every call; with hundreds of skills this is wasteful. +# Cache validation (mirrors hermes_cli/profiles.py::_count_skills, d5eee133e): +# - signature = per-dir max mtime of the dir AND its immediate children +# (one scandir per dir; catches skill add/remove inside categories, +# which does NOT bump the root dir's mtime), plus the disabled-set +# (config-driven — changes with no filesystem mtime bump at all) +# - a short TTL bounds staleness from in-place SKILL.md edits, which +# bump only the file's mtime, invisible to any directory signature. +# skip_disabled True/False are cached separately. +_SKILLS_CACHE: dict = {} # {cache_key: (signature, timestamp, skills_list)} +_SKILLS_CACHE_TTL_SECONDS = 30.0 +_SKILLS_CACHE_KEY_DISABLED = "with_disabled" +_SKILLS_CACHE_KEY_FILTERED = "filtered" + + +def _skills_scan_signature(dirs_to_scan, disabled) -> tuple: + """Cheap change-signature for the skill scan inputs. + + O(#dirs + #categories) stat calls, not a recursive walk. Includes the + platform the scan's ``skill_matches_platform`` filter will use (read + from ``agent.skill_utils``'s ``sys`` so test patches of that module + are honored) — the scan result is platform-dependent. + """ + from agent import skill_utils as _skill_utils + + platform = getattr(getattr(_skill_utils, "sys", None), "platform", "") + sig = [] + for d in dirs_to_scan: + try: + m = d.stat().st_mtime + except OSError: + continue + try: + with os.scandir(d) as it: + for entry in it: + try: + if entry.is_dir(follow_symlinks=False): + em = entry.stat(follow_symlinks=False).st_mtime + if em > m: + m = em + except OSError: + continue + except OSError: + pass + sig.append((str(d), m)) + return (tuple(sig), frozenset(disabled), platform) + # All skills live in ~/.hermes/skills/ (seeded from bundled skills/ on install). # This is the single source of truth -- agent edits, hub installs, and bundled @@ -627,22 +676,47 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: Returns: List of skill metadata dicts (name, description, category). + + Results are cached per-session; the cache is invalidated when the scan + signature changes (dir/category mtimes or the disabled-set) and expires + after a short TTL to bound staleness from in-place SKILL.md edits. """ from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files - skills = [] - seen_names: set = set() + cache_key = _SKILLS_CACHE_KEY_DISABLED if skip_disabled else _SKILLS_CACHE_KEY_FILTERED - # Load disabled set once (not per-skill) + # Load disabled set once (not per-skill). Part of the cache signature: + # disabling a skill is a config change with no filesystem mtime bump. disabled = set() if skip_disabled else _get_disabled_skill_names() - # Scan local dir first, then external dirs (local takes precedence) - dirs_to_scan = [] + # Collect directories to scan — same resolution as the scan loop below + # (_skills_dir() resolves the LIVE profile HERMES_HOME; the module-level + # SKILLS_DIR can be stale in long-lived runtimes). + dirs_to_scan: list = [] active_skills_dir = _skills_dir() if active_skills_dir.exists(): dirs_to_scan.append(active_skills_dir) dirs_to_scan.extend(get_external_skills_dirs()) + signature = _skills_scan_signature(dirs_to_scan, disabled) + now = time.monotonic() + + cached = _SKILLS_CACHE.get(cache_key) + if ( + cached is not None + and cached[0] == signature + and (now - cached[1]) < _SKILLS_CACHE_TTL_SECONDS + ): + # Per-call shallow copies: callers mutate the returned dicts + # (e.g. web_server annotates s["enabled"]/s["usage"]) — handing + # out the cached objects would poison the cache for everyone else. + return [dict(s) for s in cached[2]] + + skills = [] + seen_names: set = set() + + # Scan local dir first, then external dirs (local takes precedence) — + # dirs_to_scan already resolved above for the signature. for scan_dir in dirs_to_scan: for skill_md in iter_skill_index_files(scan_dir, "SKILL.md"): if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts): @@ -695,7 +769,12 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: ) continue - return skills + # Store in cache keyed by the scan signature computed BEFORE the scan + # (a write racing the scan changes the signature, so the next call + # re-scans rather than serving the torn result past the TTL). Same + # shallow-copy contract as the hit path — the caller may mutate. + _SKILLS_CACHE[cache_key] = (signature, now, skills) + return [dict(s) for s in skills] def _sort_skills(skills: List[Dict[str, Any]]) -> List[Dict[str, Any]]: diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 44ef03af7887..1d243f4d2fc1 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1988,6 +1988,7 @@ def _resolve_command_cwd( workdir: Optional[str], env: Any, default_cwd: str, + prev_owner: Optional[str] = None, ) -> str: """Return the cwd for a command, preferring the live session cwd. @@ -1996,12 +1997,29 @@ def _resolve_command_cwd( new directory in ``env.cwd``, but foreground/background calls kept forcing the old cwd back through ``env.execute(..., cwd=...)``. Explicit ``workdir=`` must still override everything. + + When ``prev_owner`` is provided and differs from the current session, + ``env.cwd`` was mutated by a *different* session's ``cd`` and must NOT be + trusted — fall through to ``default_cwd`` (the config/override cwd) so + the command runs in this session's own workspace, not the previous + session's leftover checkout. This mirrors the ``_live_cwd_if_owned`` + guard file_tools uses for the same shared-env problem. """ if workdir: return workdir live_cwd = getattr(env, "cwd", None) if isinstance(live_cwd, str) and live_cwd.strip(): + # The env is shared (collapsed to "default"); its cwd tracks the LAST + # session that ran a command. If a different session owned the env + # before this call claimed it, env.cwd is that session's leftover `cd` + # — not ours. Don't use it. + if prev_owner is not None: + session_key = getattr(env, "cwd_owner", "") + # cwd_owner was already overwritten to the current session at the + # call site, so compare against the captured previous owner. + if prev_owner and prev_owner != "default" and session_key != prev_owner: + return default_cwd return live_cwd return default_cwd @@ -2295,6 +2313,8 @@ def terminal_tool( "command": approval.get("command", command), "description": approval.get("description", "command flagged"), "pattern_key": approval.get("pattern_key", ""), + "smart_denied": approval.get("smart_denied", False), + "allow_permanent": approval.get("allow_permanent", True), }, ensure_ascii=False) # Command was blocked desc = approval.get("description", "command flagged") @@ -2352,6 +2372,10 @@ def terminal_tool( from tools.approval import get_current_session_key session_key = get_current_session_key(default="") or (task_id or "") + # Capture the env's previous owner BEFORE claiming it — _resolve_command_cwd + # needs to know whether env.cwd was left by a *different* session's `cd` + # (in which case it's stale for this session and must be ignored). + prev_cwd_owner = getattr(env, "cwd_owner", "") or "" try: env.cwd_owner = session_key except Exception: @@ -2367,6 +2391,7 @@ def terminal_tool( workdir=workdir, env=env, default_cwd=cwd, + prev_owner=prev_cwd_owner, ) try: if env_type == "local": @@ -2627,10 +2652,17 @@ def terminal_tool( workdir=workdir, env=env, default_cwd=cwd, + prev_owner=prev_cwd_owner, ) execute_kwargs = { "timeout": effective_timeout, "cwd": command_cwd, + # Foreground model-facing output: cap retention while + # streaming (head/tail window) so a verbose command + # can't OOM the gateway before truncation (#64435). + # Internal env.execute() consumers (file ops cat + # reads, RPC reads) intentionally stay unbounded. + "bounded_capture": True, } result = env.execute(command, **execute_kwargs) except Exception as e: @@ -2682,9 +2714,10 @@ def terminal_tool( "command." ) - # Foreground terminal output canonicalization seam: plugins receive - # the full output string before default truncation and may only - # replace it by returning a string from transform_terminal_output. + # Foreground terminal output canonicalization seam: process capture + # is already bounded by BaseEnvironment before sudo checks and hooks + # run. Plugins may replace that bounded string; replacements are + # still subject to the final output limit below. # The hook is fail-open, and the first valid string return wins. try: from hermes_cli.plugins import invoke_hook diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 49f8cbaca226..39e92261a19c 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -99,6 +99,7 @@ GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1") OPENAI_BASE_URL = os.getenv("STT_OPENAI_BASE_URL", "https://api.openai.com/v1") XAI_STT_BASE_URL = os.getenv("XAI_STT_BASE_URL", "https://api.x.ai/v1") ELEVENLABS_STT_BASE_URL = os.getenv("ELEVENLABS_STT_BASE_URL", "https://api.elevenlabs.io/v1") +# DeepInfra STT base URL now resolved via hermes_cli.models.deepinfra_base_url (shared). SUPPORTED_FORMATS = {".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".aac", ".flac"} LOCAL_NATIVE_AUDIO_FORMATS = {".wav", ".aiff", ".aif"} @@ -122,7 +123,7 @@ def _load_stt_config() -> dict: """Load the ``stt`` section from user config, falling back to defaults.""" try: from hermes_cli.config import load_config - return load_config().get("stt", {}) + return load_config().get("stt") or {} except Exception: return {} @@ -229,7 +230,7 @@ def _try_lazy_install_stt() -> bool: return False -# Names of the 6 STT providers with native handlers in this module. +# Names of the STT providers with native handlers in this module. # Kept in sync with ``agent.transcription_registry._BUILTIN_NAMES`` — # a regression test fails if they drift. The plugin hook from # issue #30398-style follow-up rejects plugins registering under any @@ -242,6 +243,8 @@ BUILTIN_STT_PROVIDERS = frozenset({ "openai", "mistral", "xai", + "elevenlabs", + "deepinfra", }) @@ -827,11 +830,24 @@ def _get_provider(stt_config: dict) -> str: ) return "none" + if provider == "deepinfra": + if _HAS_OPENAI and (get_env_value("DEEPINFRA_API_KEY") or "").strip(): + return "deepinfra" + logger.warning( + "STT provider 'deepinfra' configured but DEEPINFRA_API_KEY not set " + "(or openai package missing)" + ) + return "none" + return provider # Unknown — let it fail downstream - # --- Auto-detect (no explicit provider): local > groq > openai > xai > elevenlabs - - # mistral is intentionally skipped while `mistralai` is quarantined on - # PyPI (malicious 2.4.6 release on 2026-05-12). + # --- Auto-detect (no explicit provider): + # local > groq > openai > mistral > xai > elevenlabs > deepinfra --- + # DeepInfra is tried LAST so adding DEEPINFRA_API_KEY (commonly set for the + # chat surface) never silently displaces an existing xAI/ElevenLabs STT + # auto-selection; a DeepInfra-only box still resolves to it. mistral is + # intentionally skipped while `mistralai` is quarantined on PyPI (malicious + # 2.4.6 release on 2026-05-12). if _HAS_FASTER_WHISPER: return "local" @@ -863,6 +879,9 @@ def _get_provider(stt_config: dict) -> str: if get_env_value("ELEVENLABS_API_KEY"): logger.info("No local STT available, using ElevenLabs Scribe STT API") return "elevenlabs" + if _HAS_OPENAI and (get_env_value("DEEPINFRA_API_KEY") or "").strip(): + logger.info("No local STT available, using DeepInfra Whisper API") + return "deepinfra" return "none" @@ -1130,7 +1149,7 @@ def _transcribe_local(file_path: str, model_name: str) -> Dict[str, Any]: # Language: config.yaml (stt.local.language) > env var > auto-detect. _forced_lang = ( - _load_stt_config().get("local", {}).get("language") + (_load_stt_config().get("local") or {}).get("language") or os.getenv(LOCAL_STT_LANGUAGE_ENV) or None ) @@ -1213,7 +1232,7 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any] # Language: config.yaml (stt.local.language) > env var > "en" default. language = ( - _load_stt_config().get("local", {}).get("language") + (_load_stt_config().get("local") or {}).get("language") or os.getenv(LOCAL_STT_LANGUAGE_ENV) or DEFAULT_LOCAL_STT_LANGUAGE ) @@ -1327,22 +1346,36 @@ def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]: # --------------------------------------------------------------------------- -def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]: - """Transcribe using OpenAI Whisper API (paid).""" - try: - api_key, base_url = _resolve_openai_audio_client_config() - except ValueError as exc: - return { - "success": False, - "transcript": "", - "error": str(exc), - } +def _transcribe_openai( + file_path: str, + model_name: str, + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + provider_label: str = "openai", +) -> Dict[str, Any]: + """Transcribe via the OpenAI ``audio.transcriptions.create`` SDK shape. + + Also serves as the shared backend for every OpenAI-compatible STT + endpoint (DeepInfra etc.) — callers pass an explicit ``api_key`` / + ``base_url`` to skip the OpenAI-only auth chain, and a + ``provider_label`` so the response carries the right ``provider`` + name. + """ + if api_key is None: + try: + api_key, fallback_base = _resolve_openai_audio_client_config() + except ValueError as exc: + return {"success": False, "transcript": "", "error": str(exc)} + base_url = base_url or fallback_base if not _HAS_OPENAI: return {"success": False, "transcript": "", "error": "openai package not installed"} - # Auto-correct model if caller passed a Groq-only model - if model_name in GROQ_MODELS: + # Auto-correct model if caller passed a Groq-only model. Only applies + # to the native OpenAI path — third-party endpoints may legitimately + # serve a whisper-large-v3 variant. + if provider_label == "openai" and model_name in GROQ_MODELS: logger.info("Model %s not available on OpenAI, using %s", model_name, DEFAULT_STT_MODEL) model_name = DEFAULT_STT_MODEL @@ -1358,10 +1391,12 @@ def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]: ) transcript_text = _extract_transcript_text(transcription) - logger.info("Transcribed %s via OpenAI API (%s, %d chars)", - Path(file_path).name, model_name, len(transcript_text)) + logger.info( + "Transcribed %s via %s (%s, %d chars)", + Path(file_path).name, provider_label, model_name, len(transcript_text), + ) - return {"success": True, "transcript": transcript_text, "provider": "openai"} + return {"success": True, "transcript": transcript_text, "provider": provider_label} finally: close = getattr(client, "close", None) if callable(close): @@ -1376,7 +1411,7 @@ def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]: except APIError as e: return {"success": False, "transcript": "", "error": f"API error: {e}"} except Exception as e: - logger.error("OpenAI transcription failed: %s", e, exc_info=True) + logger.error("%s transcription failed: %s", provider_label, e, exc_info=True) return {"success": False, "transcript": "", "error": f"Transcription failed: {e}"} # --------------------------------------------------------------------------- @@ -1447,7 +1482,7 @@ def _transcribe_xai(file_path: str, model_name: str) -> Dict[str, Any]: } stt_config = _load_stt_config() - xai_config = stt_config.get("xai", {}) + xai_config = stt_config.get("xai") or {} base_url = str( xai_config.get("base_url") or get_env_value("XAI_STT_BASE_URL") @@ -1542,7 +1577,7 @@ def _transcribe_elevenlabs(file_path: str, model_name: str) -> Dict[str, Any]: return {"success": False, "transcript": "", "error": "ELEVENLABS_API_KEY not set"} stt_config = _load_stt_config() - elevenlabs_config = stt_config.get("elevenlabs", {}) + elevenlabs_config = stt_config.get("elevenlabs") or {} base_url = str( elevenlabs_config.get("base_url") or get_env_value("ELEVENLABS_STT_BASE_URL") @@ -1616,6 +1651,59 @@ def _transcribe_elevenlabs(file_path: str, model_name: str) -> Dict[str, Any]: return {"success": False, "transcript": "", "error": f"ElevenLabs STT transcription failed: {e}"} +# --------------------------------------------------------------------------- +# Provider: DeepInfra (OpenAI-compatible /v1/audio/transcriptions) +# --------------------------------------------------------------------------- + + +def _transcribe_deepinfra(file_path: str, model_name: str) -> Dict[str, Any]: + """Resolve DeepInfra credentials/model, then delegate to the OpenAI handler. + + DeepInfra's STT endpoint is OpenAI-compatible, so the actual SDK + call lives in :func:`_transcribe_openai` — this wrapper only owns + DeepInfra-specific credential and model resolution, using the shared + ``hermes_cli.models`` helpers so every DeepInfra surface resolves the + base URL and model ids identically. + """ + api_key = (get_env_value("DEEPINFRA_API_KEY") or "").strip() + if not api_key: + return {"success": False, "transcript": "", "error": "DEEPINFRA_API_KEY not set"} + + from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids + + stt_config = _load_stt_config() + # ``stt.deepinfra: null`` in YAML yields None, not {} — coalesce so the + # ``.get`` calls don't raise (no stt.deepinfra block in DEFAULT_CONFIG to + # deep-merge over the null). + di_config = stt_config.get("deepinfra") if isinstance(stt_config, dict) else None + if not isinstance(di_config, dict): + di_config = {} + base_url = deepinfra_base_url(di_config) + + if not model_name: + candidates = deepinfra_model_ids("stt") + if not candidates: + return { + "success": False, + "transcript": "", + "error": ( + "No DeepInfra STT model available. Pin one in " + "config.yaml under stt.deepinfra.model, or check " + "connectivity to api.deepinfra.com so the live catalog " + "can be fetched." + ), + } + model_name = candidates[0] + + return _transcribe_openai( + file_path, + model_name, + api_key=api_key, + base_url=base_url, + provider_label="deepinfra", + ) + + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @@ -1657,14 +1745,14 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A provider = _get_provider(stt_config) if provider == "local": - local_cfg = stt_config.get("local", {}) + local_cfg = stt_config.get("local") or {} model_name = _normalize_local_model( model or local_cfg.get("model", DEFAULT_LOCAL_MODEL) ) return _transcribe_local(file_path, model_name) if provider == "local_command": - local_cfg = stt_config.get("local", {}) + local_cfg = stt_config.get("local") or {} model_name = _normalize_local_command_model( model or local_cfg.get("model", DEFAULT_LOCAL_MODEL) ) @@ -1675,12 +1763,12 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A return _transcribe_groq(file_path, model_name) if provider == "openai": - openai_cfg = stt_config.get("openai", {}) + openai_cfg = stt_config.get("openai") or {} model_name = model or openai_cfg.get("model", DEFAULT_STT_MODEL) return _transcribe_openai(file_path, model_name) if provider == "mistral": - mistral_cfg = stt_config.get("mistral", {}) + mistral_cfg = stt_config.get("mistral") or {} model_name = model or mistral_cfg.get("model", DEFAULT_MISTRAL_STT_MODEL) return _transcribe_mistral(file_path, model_name) @@ -1690,10 +1778,16 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A return _transcribe_xai(file_path, model_name) if provider == "elevenlabs": - elevenlabs_cfg = stt_config.get("elevenlabs", {}) + elevenlabs_cfg = stt_config.get("elevenlabs") or {} model_name = model or elevenlabs_cfg.get("model_id", DEFAULT_ELEVENLABS_STT_MODEL) return _transcribe_elevenlabs(file_path, model_name) + if provider == "deepinfra": + di_config = stt_config.get("deepinfra") # may be None (YAML null) + di_config = di_config if isinstance(di_config, dict) else {} + model_name = model or di_config.get("model") or "" + return _transcribe_deepinfra(file_path, model_name) + # User-declared command-type provider # (``stt.providers.: type: command``). Fires after the built-in # elif chain — built-in names short-circuit upstream so a user's @@ -1754,7 +1848,7 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A def _resolve_openai_audio_client_config() -> tuple[str, str]: """Return direct OpenAI audio config or a managed gateway fallback.""" stt_config = _load_stt_config() - openai_cfg = stt_config.get("openai", {}) + openai_cfg = stt_config.get("openai") or {} cfg_api_key = openai_cfg.get("api_key", "") cfg_base_url = openai_cfg.get("base_url", "") if cfg_api_key: diff --git a/tools/tts_tool.py b/tools/tts_tool.py index e2a96fb4ad7b..545d72bb6907 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -50,7 +50,7 @@ import threading import uuid from pathlib import Path from typing import Callable, Dict, Any, Optional -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse from hermes_cli._subprocess_compat import windows_hide_flags from hermes_constants import display_hermes_home @@ -205,6 +205,8 @@ DEFAULT_GEMINI_TTS_VOICE = "Kore" DEFAULT_GEMINI_TTS_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" DEFAULT_GEMINI_AUDIO_TAGS = False GEMINI_AUDIO_TAG_REWRITE_TASK = "tts_audio_tags" +# Base URL now resolved via hermes_cli.models.deepinfra_base_url (shared). +DEFAULT_DEEPINFRA_TTS_VOICE = "default" # PCM output specs for Gemini TTS (fixed by the API) GEMINI_TTS_SAMPLE_RATE = 24000 GEMINI_TTS_CHANNELS = 1 @@ -341,7 +343,7 @@ def _load_tts_config() -> Dict[str, Any]: try: from hermes_cli.config import load_config config = load_config() - return config.get("tts", {}) + return config.get("tts") or {} except ImportError: logger.debug("hermes_cli.config not available, using default TTS config") return {} @@ -351,7 +353,12 @@ def _load_tts_config() -> Dict[str, Any]: def _get_provider(tts_config: Dict[str, Any]) -> str: - """Get the configured TTS provider name.""" + """Get the explicitly configured TTS provider or the free default. + + Inference credentials do not imply consent to paid speech generation. + Users opt into cloud TTS by setting ``tts.provider`` (normally through + ``hermes tools``); otherwise the historical Edge backend remains active. + """ return (tts_config.get("provider") or DEFAULT_PROVIDER).lower().strip() @@ -397,6 +404,7 @@ BUILTIN_TTS_PROVIDERS = frozenset({ "neutts", "kittentts", "piper", + "deepinfra", }) DEFAULT_COMMAND_TTS_TIMEOUT_SECONDS = 120 @@ -949,7 +957,7 @@ async def _generate_edge_tts(text: str, output_path: str, tts_config: Dict[str, Path to the saved audio file. """ _edge_tts = _import_edge_tts() - edge_config = tts_config.get("edge", {}) + edge_config = tts_config.get("edge") or {} voice = edge_config.get("voice", DEFAULT_EDGE_VOICE) speed = float(edge_config.get("speed", tts_config.get("speed", 1.0))) @@ -982,7 +990,7 @@ def _generate_elevenlabs(text: str, output_path: str, tts_config: Dict[str, Any] if not api_key: raise ValueError("ELEVENLABS_API_KEY not set. Get one at https://elevenlabs.io/") - el_config = tts_config.get("elevenlabs", {}) + el_config = tts_config.get("elevenlabs") or {} voice_id = el_config.get("voice_id", DEFAULT_ELEVENLABS_VOICE_ID) model_id = el_config.get("model_id", DEFAULT_ELEVENLABS_MODEL_ID) @@ -1009,36 +1017,92 @@ def _generate_elevenlabs(text: str, output_path: str, tts_config: Dict[str, Any] return output_path +def _tts_response_format_from_path(output_path: str) -> str: + """Pick an OpenAI-compatible TTS response format from the output extension.""" + if output_path.endswith(".ogg"): + return "opus" + if output_path.endswith(".wav"): + return "wav" + if output_path.endswith(".flac"): + return "flac" + return "mp3" + + # =========================================================================== -# Provider: OpenAI TTS +# Provider: OpenAI TTS (also used by every OpenAI-compatible TTS endpoint — +# DeepInfra delegates here via _generate_deepinfra_tts). # =========================================================================== -def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str: - """ - Generate audio using OpenAI TTS. +def _generate_openai_tts( + text: str, + output_path: str, + tts_config: Dict[str, Any], + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + model: Optional[str] = None, + voice: Optional[str] = None, + speed: Optional[float] = None, +) -> str: + """Generate audio via the OpenAI ``audio.speech.create`` SDK shape. + + Optional kwargs let OpenAI-compatible backends (DeepInfra etc.) reuse + this function — they resolve credentials/model themselves and pass + them through, skipping the OpenAI-only ``_resolve_openai_audio_client_config``. Args: text: Text to convert. output_path: Where to save the audio file. - tts_config: TTS config dict. + tts_config: TTS config dict (used for ``tts.openai`` sub-block + and the global ``speed`` default). + api_key: Bearer token. When None, resolved from the OpenAI auth + chain (config → env → managed gateway). + base_url: API base URL. When None, falls back to + ``tts.openai.base_url`` then the OpenAI default. + model: Model id. When None, reads ``tts.openai.model``. + voice: Voice id. When None, reads ``tts.openai.voice``. + speed: Playback speed. When None, reads ``tts.openai.speed`` / + ``tts.speed``. Returns: Path to the saved audio file. """ - api_key, base_url, is_managed = _resolve_openai_audio_client_config() + # Only resolve the OpenAI auth chain when the caller didn't pass explicit + # credentials. OpenAI-compatible backends (DeepInfra) pass api_key / + # base_url / model / voice through and never hit the managed-gateway path. + fallback_base: Optional[str] = None + is_managed = False + explicit_base_url = base_url is not None + if api_key is None: + api_key, fallback_base, is_managed = _resolve_openai_audio_client_config() - oai_config = tts_config.get("openai", {}) - model = oai_config.get("model", DEFAULT_OPENAI_MODEL) - voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE) - custom_base_url = oai_config.get("base_url") - if custom_base_url: - base_url = custom_base_url - speed = float(oai_config.get("speed", tts_config.get("speed", 1.0))) + # ``tts.openai: null`` in YAML yields None — coalesce so .get() is safe. + oai_config = (tts_config.get("openai") if isinstance(tts_config, dict) else None) or {} + if model is None: + model = oai_config.get("model", DEFAULT_OPENAI_MODEL) + if voice is None: + voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE) + config_base_url = oai_config.get("base_url") + if base_url is None: + # Config override wins over the auth-chain fallback (restores the + # pre-refactor precedence, where tts.openai.base_url beat the resolved + # default); the auth-chain value is the last-resort default. An + # explicit base_url arg from an OpenAI-compatible caller (DeepInfra) + # skips this block entirely and always wins. + base_url = config_base_url or fallback_base or DEFAULT_OPENAI_BASE_URL + if speed is None: + speed_default = tts_config.get("speed", 1.0) if isinstance(tts_config, dict) else 1.0 + speed = float(oai_config.get("speed", speed_default)) # The managed OpenAI audio gateway only proxies MANAGED_OPENAI_TTS_MODELS. # A model set for direct OpenAI (e.g. "tts-1-hd") 400s there with # "Unsupported managed OpenAI speech model", so coerce it — unless the user # redirected base_url to their own endpoint, in which case respect it. - if is_managed and not custom_base_url and model not in MANAGED_OPENAI_TTS_MODELS: + if ( + is_managed + and not explicit_base_url + and not config_base_url + and model not in MANAGED_OPENAI_TTS_MODELS + ): logger.warning( "TTS: managed OpenAI audio gateway does not support model %r; " "falling back to %s. Set VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY " @@ -1047,16 +1111,12 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any] ) model = DEFAULT_OPENAI_MODEL - # Determine response format from extension - if output_path.endswith(".ogg"): - response_format = "opus" - else: - response_format = "mp3" + response_format = _tts_response_format_from_path(output_path) OpenAIClient = _import_openai_client() client = OpenAIClient(api_key=api_key, base_url=base_url) try: - create_kwargs = { + create_kwargs: Dict[str, Any] = { "model": model, "voice": voice, "input": text, @@ -1075,6 +1135,64 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any] close() +# =========================================================================== +# Provider: DeepInfra TTS +# =========================================================================== +# +# DeepInfra serves TTS over an OpenAI-compatible /v1/openai/audio/speech +# endpoint. Models are discovered live via the shared catalog helper +# (filtered by the ``tts`` surface tag) — no hardcoded model ids in this +# file, so retired models disappear from hermes the next time the +# catalog is fetched without a patch. + + +def _generate_deepinfra_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str: + """Resolve DeepInfra credentials/model, then delegate to the OpenAI handler. + + DeepInfra's audio endpoint is OpenAI-compatible, so there's no need + to duplicate the SDK call — we just pass an explicit api_key / + base_url / model / voice through. Model ids and the base URL come from + the shared ``hermes_cli.models`` helpers so every DeepInfra surface + resolves them identically. + """ + api_key = (get_env_value("DEEPINFRA_API_KEY") or "").strip() + if not api_key: + raise ValueError( + "DEEPINFRA_API_KEY not set. Run `hermes setup` to configure, " + "or set the env var directly." + ) + + # ``tts.deepinfra: null`` in YAML yields None, not {} — coalesce so the + # ``.get`` calls below don't raise AttributeError (there is no + # tts.deepinfra block in DEFAULT_CONFIG to deep-merge over the null). + di_config = tts_config.get("deepinfra") if isinstance(tts_config, dict) else None + if not isinstance(di_config, dict): + di_config = {} + + from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids + + model = di_config.get("model") + if not isinstance(model, str) or not model.strip(): + candidates = deepinfra_model_ids("tts") + if not candidates: + raise ValueError( + "No DeepInfra TTS model available. Pin one in config.yaml " + "under tts.deepinfra.model, or check connectivity to " + "api.deepinfra.com so the live catalog can be fetched." + ) + model = candidates[0] + return _generate_openai_tts( + text, + output_path, + tts_config, + api_key=api_key, + base_url=deepinfra_base_url(di_config), + model=model, + voice=di_config.get("voice", DEFAULT_DEEPINFRA_TTS_VOICE), + speed=float(di_config.get("speed", tts_config.get("speed", 1.0))), + ) + + # =========================================================================== # Provider: xAI TTS # =========================================================================== @@ -1204,7 +1322,7 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) - if not api_key: raise ValueError("No xAI credentials found. Configure xAI OAuth in `hermes model` or set XAI_API_KEY.") - xai_config = tts_config.get("xai", {}) + xai_config = tts_config.get("xai") or {} voice_id = str(xai_config.get("voice_id", DEFAULT_XAI_VOICE_ID)).strip() or DEFAULT_XAI_VOICE_ID language = str(xai_config.get("language", DEFAULT_XAI_LANGUAGE)).strip() or DEFAULT_XAI_LANGUAGE sample_rate = int(xai_config.get("sample_rate", DEFAULT_XAI_SAMPLE_RATE)) @@ -1443,7 +1561,7 @@ def _generate_mistral_tts(text: str, output_path: str, tts_config: Dict[str, Any if not api_key: raise ValueError("MISTRAL_API_KEY not set. Get one at https://console.mistral.ai/") - mi_config = tts_config.get("mistral", {}) + mi_config = tts_config.get("mistral") or {} model = mi_config.get("model", DEFAULT_MISTRAL_TTS_MODEL) voice_id = mi_config.get("voice_id") or DEFAULT_MISTRAL_TTS_VOICE_ID @@ -1694,7 +1812,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any] "GEMINI_API_KEY not set. Get one at https://aistudio.google.com/app/apikey" ) - raw_gemini_config = tts_config.get("gemini", {}) + raw_gemini_config = tts_config.get("gemini") or {} gemini_config = raw_gemini_config if isinstance(raw_gemini_config, dict) else {} model = str(gemini_config.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL voice = str(gemini_config.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE @@ -1732,11 +1850,24 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any] }, } + headers = {"Content-Type": "application/json"} + if urlparse(base_url).hostname == "generativelanguage.googleapis.com": + try: + import hermes_cli as _hermes_cli + + _hermes_version = str(_hermes_cli.__version__) + except Exception: + _hermes_version = "0.0.0" + # Include Hermes client context following Gemini's partner + # integration guidance: + # https://ai.google.dev/gemini-api/docs/partner-integration + headers["X-Goog-Api-Client"] = f"hermes-agent/{_hermes_version}" + endpoint = f"{base_url}/models/{model}:generateContent" response = requests.post( endpoint, params={"key": api_key}, - headers={"Content-Type": "application/json"}, + headers=headers, json=payload, timeout=60, ) @@ -1858,7 +1989,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) -> """ import sys - neutts_config = tts_config.get("neutts", {}) + neutts_config = tts_config.get("neutts") or {} ref_audio = neutts_config.get("ref_audio", "") or _default_neutts_ref_audio() ref_text = neutts_config.get("ref_text", "") or _default_neutts_ref_text() model = neutts_config.get("model", "neuphonic/neutts-air-q4-gguf") @@ -1996,7 +2127,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) PiperVoice = _import_piper() import wave - piper_config = tts_config.get("piper", {}) if isinstance(tts_config, dict) else {} + piper_config = tts_config.get("piper") or {} if isinstance(tts_config, dict) else {} voice_name = piper_config.get("voice") or DEFAULT_PIPER_VOICE download_dir = Path(piper_config.get("voices_dir") or _get_piper_voices_dir()).expanduser() download_dir.mkdir(parents=True, exist_ok=True) @@ -2294,6 +2425,17 @@ def text_to_speech_tool( logger.info("Generating speech with OpenAI TTS...") _generate_openai_tts(text, file_str, tts_config) + elif provider == "deepinfra": + try: + _import_openai_client() + except ImportError: + return json.dumps({ + "success": False, + "error": "DeepInfra TTS uses the 'openai' SDK but it isn't installed." + }, ensure_ascii=False) + logger.info("Generating speech with DeepInfra TTS...") + _generate_deepinfra_tts(text, file_str, tts_config) + elif provider == "minimax": logger.info("Generating speech with MiniMax TTS...") _generate_minimax_tts(text, file_str, tts_config) @@ -2466,60 +2608,75 @@ def text_to_speech_tool( # Requirements check # =========================================================================== def check_tts_requirements() -> bool: + """Return whether the explicitly resolved TTS provider can run. + + Availability must mirror :func:`text_to_speech_tool` dispatch. Unrelated + cloud credentials do not make the default Edge backend usable, and an + explicitly selected backend is checked on its own requirements. """ - Check if at least one TTS provider is available. - - Edge TTS needs no API key and is the default, so if the package - is installed, TTS is available. A user-declared command provider - also satisfies the requirement. - - Returns: - bool: True if at least one provider can work. - """ - # Any configured command provider counts as available. - if _has_any_command_tts_provider(): + tts_config = _load_tts_config() + provider = _get_provider(tts_config) + command_config = _resolve_command_provider_config(provider, tts_config) + if command_config is not None: return True - try: - _import_edge_tts() - return True - except ImportError: - pass - try: - _import_elevenlabs() - if get_env_value("ELEVENLABS_API_KEY"): - return True - except ImportError: - pass - try: - _import_openai_client() - if _has_openai_audio_backend(): - return True - except ImportError: - pass - if get_env_value("MINIMAX_API_KEY"): - return True - try: - from tools.xai_http import resolve_xai_http_credentials - if resolve_xai_http_credentials().get("api_key"): + if provider == "edge": + try: + _import_edge_tts() return True + except ImportError: + return _check_neutts_available() + if provider == "elevenlabs": + try: + _import_elevenlabs() + except ImportError: + return False + return bool(get_env_value("ELEVENLABS_API_KEY")) + if provider == "openai": + try: + _import_openai_client() + except ImportError: + return False + return _has_openai_audio_backend() + if provider == "deepinfra": + try: + _import_openai_client() + except ImportError: + return False + return bool(get_env_value("DEEPINFRA_API_KEY")) + if provider == "minimax": + return bool(get_env_value("MINIMAX_API_KEY")) + if provider == "xai": + try: + from tools.xai_http import resolve_xai_http_credentials + + return bool(resolve_xai_http_credentials().get("api_key")) + except Exception: + return False + if provider == "gemini": + return bool(get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY")) + if provider == "mistral": + try: + _import_mistral_client() + except ImportError: + return False + return bool(get_env_value("MISTRAL_API_KEY")) + if provider == "neutts": + return _check_neutts_available() + if provider == "kittentts": + return _check_kittentts_available() + if provider == "piper": + return _check_piper_available() + + try: + from agent.tts_registry import get_provider + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + plugin = get_provider(provider) + return bool(plugin and plugin.is_available()) except Exception: - pass - if get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY"): - return True - try: - _import_mistral_client() - if get_env_value("MISTRAL_API_KEY"): - return True - except ImportError: - pass - if _check_neutts_available(): - return True - if _check_kittentts_available(): - return True - if _check_piper_available(): - return True - return False + return False def _resolve_openai_audio_client_config() -> tuple[str, str, bool]: @@ -2619,7 +2776,7 @@ def stream_tts_to_speaker( model_id = DEFAULT_ELEVENLABS_STREAMING_MODEL_ID tts_config = _load_tts_config() - el_config = tts_config.get("elevenlabs", {}) + el_config = tts_config.get("elevenlabs") or {} voice_id = el_config.get("voice_id", voice_id) model_id = el_config.get("streaming_model_id", el_config.get("model_id", model_id)) diff --git a/tools/video_generation_tool.py b/tools/video_generation_tool.py index fe20ca0de0b3..73e2cd01f45f 100644 --- a/tools/video_generation_tool.py +++ b/tools/video_generation_tool.py @@ -472,29 +472,19 @@ def _build_dynamic_video_schema() -> Dict[str, Any]: """ parts: List[str] = [_GENERIC_DESCRIPTION] - configured = _read_configured_video_provider() configured_model = _read_configured_video_model() - if not configured: - parts.append( - "\nNo video backend is configured. Calls will return an error " - "until the user picks one via `hermes tools` → Video Generation." - ) - return {"description": "\n".join(parts)} - - try: - from agent.video_gen_registry import get_provider - from hermes_cli.plugins import _ensure_plugins_discovered - - _ensure_plugins_discovered() - provider = get_provider(configured) - except Exception: - provider = None + # Reflect the *resolved* active provider (same resolution the handler uses + # in _resolve_active_provider): an explicit ``video_gen.provider``, or — + # when unset — the single available registered backend. Keeping the + # description in sync with execution stops the agent from being told + # "no backend configured" while a call would actually succeed. + provider = _resolve_active_provider() if provider is None: parts.append( - f"\nActive backend: {configured} (plugin not yet loaded — the " - f"tool will retry discovery on first call)." + "\nNo video backend is available. Calls will return an error " + "until the user picks one via `hermes tools` → Video Generation." ) return {"description": "\n".join(parts)} @@ -548,7 +538,7 @@ def _build_dynamic_video_schema() -> Dict[str, Any]: max_refs = caps.get("max_reference_images") or 0 if max_refs: parts.append(f"- reference_image_urls: up to {max_refs} images") - if configured == "xai": + if provider.name == "xai": parts.append( "- chaining: for edit/extend pass the public HTTPS MP4 in `video` " "or `public_url` from the prior Imagine result (files-cdn). For " diff --git a/tools/web_tools.py b/tools/web_tools.py index 409b46fa606f..7754c386a56f 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -103,6 +103,21 @@ import sys logger = logging.getLogger(__name__) +def _web_extract_url(value: Any) -> Optional[str]: + """Return a usable URL from a model-supplied extract item. + + Models sometimes forward a complete web-search result instead of its URL. + Accept the two common URL keys, but reject missing/non-string values rather + than stringifying arbitrary objects into misleading fetch targets. + """ + if isinstance(value, dict): + value = value.get("url") or value.get("href") + if not isinstance(value, str): + return None + value = value.strip() + return value or None + + # ─── Backend Selection ──────────────────────────────────────────────────────── def _env_value(name: str) -> str: @@ -132,7 +147,11 @@ def _load_web_config() -> dict: """Load the ``web:`` section from ~/.hermes/config.yaml.""" try: from hermes_cli.config import load_config - return load_config().get("web", {}) + # ``or {}``: a present-but-null ``web:`` section (YAML ``web:`` with no + # body) makes ``.get("web", {})`` return None, which would break every + # caller that does ``_load_web_config().get(...)``. Honor the ``-> dict`` + # contract so callers never see None. + return load_config().get("web") or {} except (ImportError, Exception): return {} @@ -722,7 +741,7 @@ def web_search_tool(query: str, limit: int = 5) -> str: async def web_extract_tool( - urls: List[str], + urls: List[Any], format: str = None, char_limit: Optional[int] = None, ) -> str: @@ -738,7 +757,8 @@ async def web_extract_tool( ``[IMAGE: alt]`` placeholders (real image URLs are preserved as links). Args: - urls (List[str]): List of URLs to extract content from + urls (List[Any]): URL strings or search-result objects containing a + string ``url`` or ``href`` field format (str): Desired output format ("markdown" or "html", optional) char_limit (Optional[int]): Per-page char budget sent to the model (default: web.extract_char_limit or 15000). Larger pages truncate. @@ -758,7 +778,21 @@ async def web_extract_tool( from agent.redact import _PREFIX_RE from urllib.parse import unquote normalized_urls: List[str] = [] - for _url in urls: + normalized_indices: List[int] = [] + invalid_urls: Dict[int, Dict[str, Any]] = {} + for index, item in enumerate(urls): + _url = _web_extract_url(item) + if _url is None: + invalid_urls[index] = { + "url": "", + "title": "", + "content": "", + "error": ( + f"Invalid URL item at index {index}: expected a URL string " + "or an object with a string 'url' or 'href' field" + ), + } + continue normalized_url = normalize_url_for_request(_url) if ( _PREFIX_RE.search(_url) @@ -783,6 +817,7 @@ async def web_extract_tool( ), }) normalized_urls.append(normalized_url) + normalized_indices.append(index) debug_call_data = { "parameters": { @@ -804,15 +839,17 @@ async def web_extract_tool( # ── SSRF protection — filter out private/internal URLs before any backend ── safe_urls = [] - ssrf_blocked: List[Dict[str, Any]] = [] - for url in normalized_urls: + safe_indices = [] + ssrf_blocked: Dict[int, Dict[str, Any]] = {} + for index, url in zip(normalized_indices, normalized_urls): if not await async_is_safe_url(url): - ssrf_blocked.append({ + ssrf_blocked[index] = { "url": url, "title": "", "content": "", "error": "Blocked: URL targets a private or internal network address", - }) + } else: safe_urls.append(url) + safe_indices.append(index) # Dispatch only safe URLs to the configured backend if not safe_urls: @@ -906,9 +943,25 @@ async def web_extract_tool( provider.extract, safe_urls, format=format ) - # Merge any SSRF-blocked results back in - if ssrf_blocked: - results = ssrf_blocked + results + # Reconstruct the original input order across invalid, blocked, and + # provider-processed entries. Providers are expected to preserve the + # order of the safe URL list they receive. + if invalid_urls or ssrf_blocked: + safe_results = { + index: ( + results[position] + if position < len(results) + else { + "url": safe_urls[position], + "title": "", + "content": "", + "error": "Extract backend returned no result for this URL", + } + ) + for position, index in enumerate(safe_indices) + } + by_index = {**safe_results, **ssrf_blocked, **invalid_urls} + results = [by_index[index] for index in range(len(urls))] response = {"results": results} @@ -1004,7 +1057,9 @@ def check_web_api_key() -> bool: :func:`_is_backend_available`, which delegates non-legacy names to the registry. """ - configured = _load_web_config().get("backend", "").lower().strip() + # ``or ""``: a null ``web.backend`` value yields None from ``.get``, and + # ``None.lower()`` would raise. Mirrors ``_get_backend``. + configured = (_load_web_config().get("backend") or "").lower().strip() if configured and _is_backend_available(configured): return True # Any built-in backend with credentials present. This is a boolean OR, so diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index ded460f28115..3f243153964c 100644 --- a/tui_gateway/project_tree.py +++ b/tui_gateway/project_tree.py @@ -60,6 +60,42 @@ def _segments(path: str) -> list[str]: return [s for s in re.split(r"[/\\]", (path or "").rstrip("/\\")) if s] +def _is_windows_path(path: str) -> bool: + value = (path or "").strip() + # Drive-letter (`C:\…`), UNC (`\\srv`, `//srv`), or any backslash-rooted path + # — the root-relative `\wsl.localhost\…` / `\Users\…` spellings included. A + # single leading `/` stays POSIX (case-sensitive). + return bool(re.match(r"^[A-Za-z]:[/\\]", value)) or value.startswith(("\\", "//")) + + +def _comparison_segments(path: str) -> list[str]: + """Path segments suitable for identity comparisons on any host. + + Windows paths remain case-insensitive even when tests or remote backends run + on POSIX. Display paths and emitted IDs keep their original spelling. + """ + segs = _segments(path) + return [segment.casefold() for segment in segs] if _is_windows_path(path) else segs + + +def _path_key(path: str) -> str: + """Canonical comparison key (separator/trailing-slash agnostic).""" + return "/".join(_comparison_segments(path)) + + +def _lane_key(path_or_lane: str) -> str: + """Canonicalize only the path portion of a lane id. + + Branch labels remain byte-preserved; repo/worktree paths follow platform path + identity so equivalent Windows spellings do not create duplicate lanes. + """ + for marker in ("::branch::", "::kanban"): + if marker in path_or_lane: + root, suffix = path_or_lane.split(marker, 1) + return f"{_path_key(root)}{marker}{suffix}" + return _path_key(path_or_lane) + + def base_name(path: str) -> str: segs = _segments(path) return segs[-1] if segs else "" @@ -73,8 +109,8 @@ def kanban_worktree_dir(path: str) -> Optional[str]: def _is_path_under(folder: str, target: str) -> bool: """True when ``target`` equals ``folder`` or is nested under it (segment-wise).""" - f = _segments(folder) - t = _segments(target) + f = _comparison_segments(folder) + t = _comparison_segments(target) if not f or len(f) > len(t): return False return all(f[i] == t[i] for i in range(len(f))) @@ -249,7 +285,8 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool if not placement: continue - entry = lanes.get(placement["lane_key"]) + lane_identity = _lane_key(placement["lane_key"]) + entry = lanes.get(lane_identity) if entry is None: entry = { "group": { @@ -264,7 +301,7 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool "repo_label": placement["repo_label"], "repo_path": placement["repo_path"], } - lanes[placement["lane_key"]] = entry + lanes[lane_identity] = entry entry["group"]["sessions"].append(session) repos: dict[str, dict] = {} @@ -275,7 +312,8 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool if not hydrate: group["sessions"] = [] - repo = repos.get(entry["repo_key"]) + repo_identity = _path_key(entry["repo_key"]) + repo = repos.get(repo_identity) if repo is None: repo = { "id": entry["repo_key"], @@ -284,7 +322,7 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool "groups": [], "sessionCount": 0, } - repos[entry["repo_key"]] = repo + repos[repo_identity] = repo repo["groups"].append(group) repo["sessionCount"] += count @@ -311,7 +349,12 @@ def _seed_folder_repos( empty) project body. Folders already covered by a session-derived repo (same git root) are left untouched. """ - seen = {r["id"] for r in repos} | {r["path"] for r in repos if r.get("path")} + seen = { + _path_key(value) + for repo in repos + for value in (repo.get("id"), repo.get("path")) + if value + } seeded = list(repos) for folder in folders or []: @@ -320,10 +363,11 @@ def _seed_folder_repos( continue info = resolve(raw) if resolve else None root = (info or {}).get("repo_root") or re.sub(r"[/\\]+$", "", raw) - if not root or root in seen: + root_key = _path_key(root) + if not root_key or root_key in seen: continue seeded.append({"id": root, "label": base_name(root) or root, "path": root, "groups": [], "sessionCount": 0}) - seen.add(root) + seen.add(root_key) if len(seeded) != len(repos): _disambiguate_labels(seeded) @@ -347,7 +391,7 @@ class _FolderIndex: self._by_path: dict[str, tuple[dict, int]] = {} for project in projects: for folder in project.get("folders") or []: - segs = _segments(folder.get("path") or "") + segs = _comparison_segments(folder.get("path") or "") if not segs: continue key = "/".join(segs) @@ -359,7 +403,7 @@ class _FolderIndex: def match(self, target: str) -> tuple[Optional[dict], int]: """Owning project for ``target`` by longest ancestor folder, + its depth.""" - segs = _segments(target or "") + segs = _comparison_segments(target or "") # Longest prefix first → deepest (most specific) folder wins. for end in range(len(segs), 0, -1): hit = self._by_path.get("/".join(segs[:end])) @@ -430,6 +474,7 @@ def build_tree( preview_limit: int = 3, hydrate: bool = False, is_junk_root: Optional[Callable[[str], bool]] = None, + is_junk_cwd: Optional[Callable[[str], bool]] = None, ) -> dict: """Build the authoritative project tree. @@ -437,9 +482,11 @@ def build_tree( ``sessions`` are projected session-row dicts (must carry ``id``, ``cwd``, ``git_branch``, ``git_repo_root``, ``started_at``, ``last_active``). ``discovered_repos`` are ``{"root", "label", "sessions", "last_active"}``. - ``is_junk_root`` flags roots that must never become an AUTO project (the - bare home dir, the HERMES_HOME subtree) — their sessions fall through to the - flat Recents list. User-created projects are honored regardless. + ``is_junk_root`` flags git roots that must never become an AUTO project (the + bare home dir, the HERMES_HOME subtree). ``is_junk_cwd`` is the narrower + policy for non-git session folders: selected descendants may be intentional + workspaces even when their parent tree contains Hermes state. User-created + projects are honored regardless. Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When ``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but @@ -448,6 +495,7 @@ def build_tree( """ active_projects = [p for p in projects if not p.get("archived")] _junk = is_junk_root or (lambda _root: False) + _junk_cwd = is_junk_cwd or (lambda _cwd: False) folder_index = _FolderIndex(active_projects) by_project: dict[str, list[dict]] = {} @@ -493,34 +541,67 @@ def build_tree( ) ) - # Tier 2: auto projects from leftover sessions, one per common git repo root. - by_repo: dict[str, list[dict]] = {} + # Tier 2: auto projects from leftover sessions. Prefer the common git repo + # root, then fall back to the session cwd for historical/non-git workspaces. + # The pre-Projects desktop grouped every non-empty cwd; keeping that fallback + # prevents upgrades from flattening those sessions into Recents. + by_auto_root: dict[str, dict] = {} + + def _add_auto(root: str, session: dict) -> None: + key = _path_key(root) + if not key: + return + bucket = by_auto_root.setdefault(key, {"root": root, "sessions": []}) + bucket["sessions"].append(session) + for session in unowned: root = _session_repo_root(session, resolve) if root: - by_repo.setdefault(root, []).append(session) + # A real git root uses the stricter repo policy. Do not reinterpret a + # filtered internal repo as a cwd-only project. + if not _junk(root): + _add_auto(root, session) + continue + + cwd = (session.get("cwd") or "").strip() + if not cwd or _junk_cwd(cwd): + continue + placement = _place( + cwd, + (session.get("git_branch") or "").strip(), + resolve, + (session.get("git_repo_root") or "").strip(), + ) + if placement: + _add_auto(placement["repo_key"], session) seen: set[str] = set() - for repo_root, repo_sessions in by_repo.items(): - # The home dir / HERMES_HOME subtree is config + state, never a project; - # its sessions stay loose in Recents (not scoped to a phantom project). - if _junk(repo_root): - continue - repos = _build_repos(repo_sessions, resolve, hydrate) - repo_node = next((r for r in repos if r["id"] == repo_root or r["path"] == repo_root), None) + for bucket in by_auto_root.values(): + auto_root = bucket["root"] + auto_sessions = bucket["sessions"] + auto_key = _path_key(auto_root) + repos = _build_repos(auto_sessions, resolve, hydrate) + repo_node = next( + ( + repo + for repo in repos + if _path_key(repo.get("id") or repo.get("path") or "") == auto_key + ), + None, + ) if repo_node is None: continue - seen.add(repo_root) - scoped_ids.extend(s["id"] for s in repo_sessions if s.get("id")) + seen.add(auto_key) + scoped_ids.extend(s["id"] for s in auto_sessions if s.get("id")) result.append( _project_node( - pid=repo_root, - label=base_name(repo_root) or repo_root, - path=repo_root, + pid=auto_root, + label=base_name(auto_root) or auto_root, + path=auto_root, repos=repos, session_count=repo_node["sessionCount"], - last_active=_last_active(repo_sessions), - preview_sessions=_previews(repo_sessions), + last_active=_last_active(auto_sessions), + preview_sessions=_previews(auto_sessions), is_auto=True, ) ) @@ -533,9 +614,10 @@ def build_tree( continue info = resolve(raw_root) if resolve else None root = (info or {}).get("repo_root") or raw_root - if root in seen or _junk(root) or _project_for_path(folder_index, root): + root_key = _path_key(root) + if root_key in seen or _junk(root) or _project_for_path(folder_index, root): continue - seen.add(root) + seen.add(root_key) label = repo.get("label") or base_name(root) or root result.append( _project_node( diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8357de83daed..2f6e833934f6 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -568,24 +568,21 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No history = list(session.get("history", [])) # ── Persist unflushed messages to SQLite ────────────────────────── - # Two sources, tried in order of freshness: - # 1. agent._session_messages — set by the last _persist_session() - # call inside run_conversation(). This is the most recent - # snapshot the agent thread wrote, and may include partial - # turn data that hasn't reached session["history"] yet. - # 2. session["history"] — updated after run_conversation() - # returns. Stale when the agent is mid‑turn, but correct - # when the turn completed before finalize. - # Best‑effort — the agent thread may still be mid‑turn, so only - # previously completed messages are guaranteed. + # Flush ``agent._session_messages`` via ``_persist_session``'s marker-based + # dedup (same contract as the gateway-shutdown flush, #13121). Do NOT pass + # ``conversation_history``: ``session["history"]`` and ``_session_messages`` + # alias the SAME list once a turn completes, so passing it made + # ``_flush_messages_to_session_db`` treat every message as already-durable + # and skip it — a data-loss bug when finalize is the sole persist path after + # a WS disconnect/restart (e.g. the in-turn flush hit a transient SQLite + # failure). Markers persist the genuinely-unflushed tail without duplicating + # durable rows (including a resumed-but-not-run session's already-in-DB + # transcript, which stays in ``session["history"]`` only). if agent is not None and hasattr(agent, "_persist_session"): - snapshot = ( - getattr(agent, "_session_messages", None) - or history - ) + snapshot = getattr(agent, "_session_messages", None) if snapshot: try: - agent._persist_session(snapshot, conversation_history=history) + agent._persist_session(snapshot) except Exception: pass @@ -1155,6 +1152,13 @@ def _emit_approval_request(sid: str, data: dict | None) -> None: platforms and the SSE/API stream fixed in #50767). Reuse the shared gateway seam so all approval transports redact consistently.""" payload = dict(data or {}) + if "choices" not in payload: + if payload.get("smart_denied"): + payload["choices"] = ["once", "deny"] + elif payload.get("allow_permanent") is False: + payload["choices"] = ["once", "session", "deny"] + elif "allow_permanent" in payload: + payload["choices"] = ["once", "session", "always", "deny"] if "command" in payload: from gateway.run import _redact_approval_command @@ -1862,7 +1866,10 @@ def _persist_session_git_meta(session: dict, cwd: str) -> None: def _set_session_cwd(session: dict, cwd: str) -> str: - resolved = os.path.abspath(os.path.expanduser(str(cwd))) + from hermes_constants import translate_cwd_for_wsl_backend + + cwd = translate_cwd_for_wsl_backend(str(cwd)) + resolved = os.path.abspath(os.path.expanduser(cwd)) if not os.path.isdir(resolved): raise ValueError(f"working directory does not exist: {cwd}") session["cwd"] = resolved @@ -2038,15 +2045,26 @@ def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str: _pending[rid] = (sid, ev) payload["request_id"] = rid _pending_prompt_payloads[rid] = (event, dict(payload)) + answered = False + answer = "" + answer_present = False try: _emit(event, sid, payload) - ev.wait(timeout=timeout) + answered = ev.wait(timeout=timeout) finally: with _prompt_lock: _pending.pop(rid, None) _pending_prompt_payloads.pop(rid, None) - with _prompt_lock: - return _answers.pop(rid, "") + answer_present = rid in _answers + answer = _answers.pop(rid, "") + + if not answered and not answer_present and event in {"secret.request", "sudo.request"}: + _emit( + f"{event.removesuffix('.request')}.expire", + sid, + {"request_id": rid}, + ) + return answer def _clear_pending(sid: str | None = None) -> None: @@ -2099,7 +2117,15 @@ def _resolve_model() -> str: return str(m.get("default", "") or "").strip() if isinstance(m, str) and m: return m.strip() - return "anthropic/claude-sonnet-4" + # No env seed and no config preference: fall back to the cost-safe silent + # default (catalog-labeled, cache-only read), never an expensive Anthropic + # flagship the user didn't pick. + try: + from hermes_cli.models import get_preferred_silent_default_model + + return get_preferred_silent_default_model() + except Exception: + return "z-ai/glm-5.2" def _resolve_session_platform() -> str: @@ -2475,6 +2501,19 @@ def _write_config_key(key_path: str, value): _STATUSBAR_MODES = frozenset({"off", "top", "bottom"}) +_APPROVAL_MODES = frozenset({"manual", "smart", "off"}) + + +def _load_approval_mode() -> str: + from hermes_cli.config import DEFAULT_CONFIG, _deep_merge + from tools.approval import _normalize_approval_mode + + raw_cfg = _load_cfg() + cfg = _deep_merge(DEFAULT_CONFIG, raw_cfg if isinstance(raw_cfg, dict) else {}) + approvals = cfg.get("approvals") + raw = approvals.get("mode") if isinstance(approvals, dict) else None + mode = _normalize_approval_mode(raw) + return mode if mode in _APPROVAL_MODES else "manual" def _coerce_statusbar(raw) -> str: @@ -2531,15 +2570,17 @@ def _display_mouse_tracking(display: dict) -> str: return "all" -def _load_reasoning_config() -> dict | None: - from hermes_constants import parse_reasoning_effort +def _load_reasoning_config(model: str = "") -> dict | None: + """Load reasoning effort from config.yaml, respecting per-model overrides. - # Pass the raw value through — ``or ""`` would coerce a YAML boolean - # False (``reasoning_effort: false``/``off``/``no``) to "", silently - # re-enabling thinking for users who explicitly turned it off. - return parse_reasoning_effort( - (_load_cfg().get("agent") or {}).get("reasoning_effort", "") - ) + Thin wrapper over the shared chokepoint + :func:`hermes_constants.resolve_reasoning_config` (per-model override > + global ``agent.reasoning_effort``; YAML boolean False = disabled). + Closes #21256. + """ + from hermes_constants import resolve_reasoning_config + + return resolve_reasoning_config(_load_cfg(), model) def _load_service_tier() -> str | None: @@ -3310,7 +3351,8 @@ def _current_profile_name() -> str: # checkout), surfacing a one-click "update to align" prompt instead of failing # cryptically downstream. Bump whenever the desktop's backend contract changes. # v2: adds the file.attach RPC (remote-gateway non-image file upload). -DESKTOP_BACKEND_CONTRACT = 2 +# v3: adds approvals.mode config RPCs and session.info reconciliation. +DESKTOP_BACKEND_CONTRACT = 3 def _session_info(agent, session: dict | None = None) -> dict: @@ -3344,17 +3386,15 @@ def _session_info(agent, session: dict | None = None) -> dict: # the desktop status bar (it would show YOLO "off" while approvals.mode=off # silently auto-approves every dangerous command). yolo = False + approval_mode = "manual" try: - from tools.approval import ( - _YOLO_MODE_FROZEN, - _get_approval_mode, - is_session_yolo_enabled, - ) + from tools.approval import _YOLO_MODE_FROZEN, is_session_yolo_enabled session_yolo = ( bool(is_session_yolo_enabled(session_key)) if session_key else False ) - yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or _get_approval_mode() == "off" + approval_mode = _load_approval_mode() + yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or approval_mode == "off" except Exception: yolo = False info: dict = { @@ -3364,6 +3404,7 @@ def _session_info(agent, session: dict | None = None) -> dict: "service_tier": service_tier, "fast": service_tier == "priority", "yolo": yolo, + "approval_mode": approval_mode, "tools": {}, "skills": {}, "cwd": cwd, @@ -3669,6 +3710,19 @@ def _on_tool_progress( # the stable tool id and args. Emitting another id-less progress row # here makes the desktop live view diverge from hydrated history. return + if event_type == "tool.output_risk" and name: + metadata = _kwargs.get("risk_metadata") + if not isinstance(metadata, dict): + return + payload: dict[str, object] = { + "tool_id": str(_kwargs.get("tool_call_id") or ""), + "name": str(name), + "risk": str(metadata.get("risk") or "low"), + "findings": [str(item) for item in metadata.get("findings", [])], + "redacted": bool(metadata.get("redacted", False)), + } + _emit("tool.output_risk", sid, payload) + return if event_type == "reasoning.available" and preview: payload: dict[str, object] = {"text": str(preview)} if _session_verbose(sid): @@ -3860,6 +3914,9 @@ def _agent_cbs(sid: str) -> dict: "tool_gen_callback": lambda name: _tool_progress_enabled(sid) and _emit("tool.generating", sid, {"name": name}), "thinking_callback": lambda text: _emit("thinking.delta", sid, {"text": text}), + # Affection reaction (ily / <3 / good bot) → hearts. Core-detected, so + # the TUI heart and desktop floating hearts share one signal. + "reaction_callback": lambda kind: _emit("reaction", sid, {"kind": kind}), "reasoning_callback": lambda text: _emit( "reasoning.delta", sid, @@ -4164,7 +4221,7 @@ def _background_agent_kwargs(agent, task_id: str) -> dict: "openrouter_min_coding_score": getattr(agent, "openrouter_min_coding_score", None), "session_id": task_id, "reasoning_config": getattr(agent, "reasoning_config", None) - or _load_reasoning_config(), + or _load_reasoning_config(str(getattr(agent, "model", "") or "")), "service_tier": getattr(agent, "service_tier", None) or _load_service_tier(), "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), "platform": "tui", @@ -4593,7 +4650,7 @@ def _make_agent( reasoning_config=( reasoning_config_override if reasoning_config_override is not None - else _load_reasoning_config() + else _load_reasoning_config(str(model or "")) ), service_tier=( service_tier_override @@ -5314,7 +5371,7 @@ def _(rid, params: dict) -> dict: fetch_limit = max(limit * 2, 200) rows = [ s - for s in db.list_sessions_rich(source=None, limit=fetch_limit, order_by_last_active=True) + for s in db.list_sessions_rich(source=None, limit=fetch_limit, order_by_last_active=True, compact_rows=True) if (s.get("source") or "").strip().lower() not in deny ][:limit] return _ok( @@ -5361,7 +5418,7 @@ def _(rid, params: dict) -> dict: # users (lots of recent ``tool`` rows) don't get a false # "no eligible session" answer. ``session.list`` uses a # similar over-fetch strategy. - rows = db.list_sessions_rich(source=None, limit=200, order_by_last_active=True) + rows = db.list_sessions_rich(source=None, limit=200, order_by_last_active=True, compact_rows=True) for row in rows: src = (row.get("source") or "").strip().lower() if src in deny: @@ -8406,7 +8463,11 @@ def _(rid, params: dict) -> dict: @method("prompt.submit") def _(rid, params: dict) -> dict: - sid, text = params.get("session_id", ""), params.get("text", "") + from hermes_cli.input_sanitize import sanitize_user_prompt_text + + sid = params.get("session_id", "") + raw_text = params.get("text", "") + text = sanitize_user_prompt_text(raw_text) if isinstance(raw_text, str) else raw_text truncate_user_ordinal = params.get("truncate_before_user_ordinal") session, err = _sess_nowait(params, rid) if err: @@ -8746,10 +8807,18 @@ def _notification_poller_loop( continue rid = f"__notif__{int(time.time() * 1000)}" + from tools.async_delegation import ( + claim_event_delivery, complete_event_delivery, release_event_delivery, + ) + _claim = claim_event_delivery(evt, "tui-poller") + if _claim is None: + continue try: _emit("message.start", sid) _run_prompt_submit(rid, sid, session, text) + complete_event_delivery(evt, _claim) except Exception as exc: + release_event_delivery(evt, _claim) print( f"[tui_gateway] notification poller dispatch failed: " f"{type(exc).__name__}: {exc}", @@ -8798,10 +8867,18 @@ def _notification_poller_loop( session["running"] = True rid = f"__notif__{int(time.time() * 1000)}" + from tools.async_delegation import ( + claim_event_delivery, complete_event_delivery, release_event_delivery, + ) + _claim = claim_event_delivery(evt, "tui-poller") + if _claim is None: + continue try: _emit("message.start", sid) _run_prompt_submit(rid, sid, session, text) + complete_event_delivery(evt, _claim) except Exception as exc: + release_event_delivery(evt, _claim) print( f"[tui_gateway] notification poller dispatch failed: " f"{type(exc).__name__}: {exc}", @@ -9351,10 +9428,18 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: process_registry.completion_queue.put(_evt) break session["running"] = True + from tools.async_delegation import ( + claim_event_delivery, complete_event_delivery, release_event_delivery, + ) + _claim = claim_event_delivery(_evt, "tui-post-turn") + if _claim is None: + continue try: _emit("message.start", sid) _run_prompt_submit(rid, sid, session, synth) + complete_event_delivery(_evt, _claim) except Exception as _n_exc: + release_event_delivery(_evt, _claim) print( f"[tui_gateway] completion notification dispatch failed: " f"{type(_n_exc).__name__}: {_n_exc}", @@ -10150,11 +10235,13 @@ def _(rid, params: dict) -> dict: # ── Methods: respond ───────────────────────────────────────────────── -def _respond(rid, params, key): +def _respond(rid, params, key, *, allow_expired=False): r = params.get("request_id", "") with _prompt_lock: entry = _pending.get(r) if not entry: + if allow_expired and r: + return _ok(rid, {"status": "expired"}) return _err(rid, 4009, f"no pending {key} request") _, ev = entry _answers[r] = params.get(key, "") @@ -10175,12 +10262,12 @@ def _(rid, params: dict) -> dict: @method("sudo.respond") def _(rid, params: dict) -> dict: - return _respond(rid, params, "password") + return _respond(rid, params, "password", allow_expired=True) @method("secret.respond") def _(rid, params: dict) -> dict: - return _respond(rid, params, "value") + return _respond(rid, params, "value", allow_expired=True) @method("approval.respond") @@ -10370,6 +10457,22 @@ def _(rid, params: dict) -> dict: agent.verbose_logging = nv == "verbose" return _ok(rid, {"key": key, "value": nv}) + if key in {"approval_mode", "approvals.mode"}: + raw = str(value or "").strip().lower() + if raw not in _APPROVAL_MODES: + return _err( + rid, + 4002, + f"unknown approval mode: {value}; pick one of manual|smart|off", + ) + + _write_config_key("approvals.mode", raw) + for sid, sess in list(_sessions.items()): + agent = sess.get("agent") + if agent is not None: + _emit("session.info", sid, _session_info(agent, sess)) + return _ok(rid, {"key": "approvals.mode", "value": raw}) + if key == "yolo": # Approval bypass. Two scopes: # scope="session" (default) — same as the TUI's Shift+Tab. Toggles @@ -10907,6 +11010,25 @@ def _is_repo_junk(root: str) -> bool: return real == home or real == hermes_home or real.startswith(hermes_home + os.sep) +def _is_session_cwd_junk(cwd: str) -> bool: + """A non-git cwd that should stay in flat Recents rather than auto-group. + + Unlike discovered git roots, an explicitly selected descendant of + HERMES_HOME may be an intentional prose/data workspace. The pre-Projects + desktop surfaced every such cwd, so exclude only the two broad defaults + that would create catch-all projects. + """ + if not cwd: + return True + + from hermes_constants import get_hermes_home + + real = os.path.normcase(os.path.realpath(cwd)) + home = os.path.normcase(os.path.realpath(os.path.expanduser("~"))) + hermes_home = os.path.normcase(os.path.realpath(str(get_hermes_home()))) + return real == home or real == hermes_home + + def _discover_repos_payload(db, *, conn=None, backfill: bool = True) -> list[dict]: """Merge filesystem-scanned repos (cached) with session-derived repo roots. @@ -11108,6 +11230,7 @@ def _build_project_tree( preview_limit=preview_limit, hydrate=hydrate, is_junk_root=_is_repo_junk, + is_junk_cwd=_is_session_cwd_junk, ) return tree, active_id @@ -11263,6 +11386,11 @@ def _(rid, params: dict) -> dict: ) if key == "busy": return _ok(rid, {"value": _load_busy_input_mode()}) + if key in {"approval_mode", "approvals.mode"}: + try: + return _ok(rid, {"value": _load_approval_mode()}) + except Exception as e: + return _err(rid, 5001, str(e)) if key == "details_mode": allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) raw = ( @@ -11853,6 +11981,52 @@ def _(rid, params: dict) -> dict: except Exception: pass + try: + from agent.skill_bundles import ( + build_bundle_invocation_message, + get_skill_bundles, + resolve_bundle_command_key, + ) + + from hermes_cli.commands import resolve_command + + bundle_key = ( + resolve_bundle_command_key(name) + if resolve_command(name) is None + else None + ) + except Exception: + bundle_key = None + + if bundle_key is not None: + try: + bundle_result = build_bundle_invocation_message( + bundle_key, + arg, + task_id=session.get("session_key", "") if session else "", + platform=_resolve_session_platform(), + ) + except Exception as exc: + return _err(rid, 4018, f"bundle dispatch failed: {exc}") + + if not bundle_result: + return _err(rid, 4018, f"failed to load bundle: {bundle_key}") + + msg, loaded_names, missing = bundle_result + bundle_info = get_skill_bundles().get(bundle_key, {}) + bundle_name = bundle_info.get("name", bundle_key.lstrip("/")) + notice = f"⚡ Loading bundle: {bundle_name} ({len(loaded_names)} skills)" + if missing: + notice += f"\nSkipped missing skills: {', '.join(missing)}" + return _ok( + rid, + { + "type": "send", + "message": msg, + "notice": notice, + }, + ) + try: from agent.skill_commands import ( scan_skill_commands, @@ -12264,7 +12438,7 @@ def _(rid, params: dict) -> dict: except Exception as exc: return _err(rid, 5009, f"compress failed: {exc}") - return _err(rid, 4018, f"not a quick/plugin/skill command: {name}") + return _err(rid, 4018, f"not a quick/plugin/bundle/skill command: {name}") # ── Methods: paste ──────────────────────────────────────────────────── @@ -13056,10 +13230,11 @@ def _(rid, params: dict) -> dict: if not cmd: return _err(rid, 4004, "empty command") - # Skill slash commands and _pending_input commands must NOT go through the - # slash worker — see _PENDING_INPUT_COMMANDS definition above. Plugin - # commands must also avoid the worker, but unlike skills/pending-input they - # still return normal slash.exec output so the TUI keeps the pager path. + # Skill and bundle slash commands plus _pending_input commands must NOT go + # through the slash worker — see _PENDING_INPUT_COMMANDS definition above. + # Plugin commands must also avoid the worker, but unlike skills and + # pending-input commands they still return normal slash.exec output so the + # TUI keeps the pager path. _cmd_text = cmd.lstrip("/") if cmd.startswith("/") else cmd _cmd_parts = _cmd_text.split(maxsplit=1) _cmd_base = (_cmd_parts[0] if _cmd_parts else "").lower() @@ -13087,6 +13262,27 @@ def _(rid, params: dict) -> dict: "snapshot restore mutates live config/state; use command.dispatch for /snapshot restore", ) + try: + from agent.skill_bundles import resolve_bundle_command_key + from hermes_cli.commands import resolve_command + + _bundle_key = ( + resolve_bundle_command_key(_cmd_base) + if resolve_command(_cmd_base) is None + else None + ) + if _bundle_key is not None: + return _methods["command.dispatch"]( + rid, + { + "name": _bundle_key.lstrip("/"), + "arg": _cmd_arg, + "session_id": params.get("session_id", ""), + }, + ) + except Exception: + pass + try: from agent.skill_commands import get_skill_commands @@ -13409,7 +13605,7 @@ def _(rid, params: dict) -> dict: cutoff = time.time() - days * 86400 rows = [ s - for s in db.list_sessions_rich(limit=500) + for s in db.list_sessions_rich(limit=500, compact_rows=True) if (s.get("started_at") or 0) >= cutoff ] return _ok( diff --git a/tui_gateway/slash_worker.py b/tui_gateway/slash_worker.py index 00e83bedf148..34cc75953733 100644 --- a/tui_gateway/slash_worker.py +++ b/tui_gateway/slash_worker.py @@ -65,6 +65,27 @@ def _is_orphaned(original_ppid, parent_create_time, getppid=os.getppid) -> bool: return True +def _prepare_slash_worker_runtime() -> None: + """Start bounded MCP discovery before HermesCLI snapshots tools. + + Each slash_worker child is its own process — the parent ``hermes serve`` + discovery thread does not populate this registry (issue #61891). + """ + import logging + + from hermes_cli.mcp_startup import ( + start_background_mcp_discovery, + wait_for_mcp_discovery, + ) + + logger = logging.getLogger(__name__) + start_background_mcp_discovery( + logger=logger, + thread_name="slash-worker-mcp-discovery", + ) + wait_for_mcp_discovery() + + def _start_parent_death_watchdog(original_ppid, parent_create_time) -> None: def _loop(): while not _is_orphaned(original_ppid, parent_create_time): @@ -129,6 +150,7 @@ def main(): except psutil.Error: parent_create_time = 0.0 _start_parent_death_watchdog(orig_ppid, parent_create_time) + _prepare_slash_worker_runtime() with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False) diff --git a/ui-tui/.prettierrc b/ui-tui/.prettierrc deleted file mode 100644 index 12ec3ed7db1b..000000000000 --- a/ui-tui/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "arrowParens": "avoid", - "bracketSpacing": true, - "endOfLine": "auto", - "printWidth": 120, - "semi": false, - "singleQuote": true, - "tabWidth": 2, - "trailingComma": "none", - "useTabs": false -} diff --git a/ui-tui/README.md b/ui-tui/README.md index 159db8293b61..fe5ab7c8db1d 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -287,7 +287,9 @@ Primary event types the client handles today: | `clarify.request` | `{ question, choices?, request_id }` | | `approval.request` | `{ command, description, allow_permanent? }` | | `sudo.request` | `{ request_id }` | +| `sudo.expire` | `{ request_id }` clears a timed-out sudo prompt | | `secret.request` | `{ prompt, env_var, request_id }` | +| `secret.expire` | `{ request_id }` clears a timed-out secret prompt | | `background.complete` | `{ task_id, text }` | | `billing.step_up.verification` | `{ verification_url, user_code }` | | `review.summary` | `{ text }` | @@ -487,4 +489,4 @@ tui_gateway/ server.py RPC handlers and session logic render.py optional rich/ANSI bridge slash_worker.py persistent HermesCLI subprocess for slash commands -``` \ No newline at end of file +``` diff --git a/ui-tui/eslint.config.mjs b/ui-tui/eslint.config.mjs index 1b20c3244f3d..c3fed73f26ce 100644 --- a/ui-tui/eslint.config.mjs +++ b/ui-tui/eslint.config.mjs @@ -1,87 +1,7 @@ -import js from '@eslint/js' -import typescriptEslint from '@typescript-eslint/eslint-plugin' -import typescriptParser from '@typescript-eslint/parser' -import perfectionist from 'eslint-plugin-perfectionist' -import reactPlugin from 'eslint-plugin-react' -import hooksPlugin from 'eslint-plugin-react-hooks' -import unusedImports from 'eslint-plugin-unused-imports' -import globals from 'globals' - -const noopRule = { - meta: { schema: [], type: 'problem' }, - create: () => ({}) -} - -const customRules = { - rules: { - 'no-process-cwd': noopRule, - 'no-process-env-top-level': noopRule, - 'no-sync-fs': noopRule, - 'no-top-level-dynamic-import': noopRule, - 'no-top-level-side-effects': noopRule - } -} +import shared from '../eslint.config.shared.mjs' export default [ - { - ignores: ['**/node_modules/**', '**/dist/**', 'src/**/*.js'] - }, - js.configs.recommended, - { - files: ['**/*.{ts,tsx}'], - languageOptions: { - globals: { ...globals.node }, - parser: typescriptParser, - parserOptions: { - ecmaFeatures: { jsx: true }, - ecmaVersion: 'latest', - sourceType: 'module' - } - }, - plugins: { - '@typescript-eslint': typescriptEslint, - 'custom-rules': customRules, - perfectionist, - react: reactPlugin, - 'react-hooks': hooksPlugin, - 'unused-imports': unusedImports - }, - rules: { - 'no-fallthrough': ['error', { allowEmptyCase: true }], - curly: ['error', 'all'], - '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], - '@typescript-eslint/no-unused-vars': 'off', - 'no-undef': 'off', - 'no-unused-vars': 'off', - 'padding-line-between-statements': [ - 1, - { blankLine: 'always', next: ['block-like', 'block', 'return', 'if', 'class', 'continue', 'debugger', 'break', 'multiline-const', 'multiline-let'], prev: '*' }, - { blankLine: 'always', next: '*', prev: ['case', 'default', 'multiline-const', 'multiline-let', 'multiline-block-like'] }, - { blankLine: 'never', next: ['block', 'block-like'], prev: ['case', 'default'] }, - { blankLine: 'always', next: ['block', 'block-like'], prev: ['block', 'block-like'] }, - { blankLine: 'always', next: ['empty'], prev: 'export' }, - { blankLine: 'never', next: 'iife', prev: ['block', 'block-like', 'empty'] } - ], - 'perfectionist/sort-exports': ['error', { order: 'asc', type: 'natural' }], - 'perfectionist/sort-imports': [ - 'error', - { - groups: ['side-effect', 'builtin', 'external', 'internal', 'parent', 'sibling', 'index'], - order: 'asc', - type: 'natural' - } - ], - 'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }], - 'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }], - 'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }], - 'react-hooks/exhaustive-deps': 'warn', - 'react-hooks/rules-of-hooks': 'error', - 'unused-imports/no-unused-imports': 'error' - }, - settings: { - react: { version: 'detect' } - } - }, + ...shared, { files: ['packages/hermes-ink/**/*.{ts,tsx}'], rules: { @@ -91,17 +11,5 @@ export default [ 'no-redeclare': 'off', 'react-hooks/exhaustive-deps': 'off' } - }, - { - files: ['**/*.js'], - ignores: ['**/node_modules/**', '**/dist/**'], - languageOptions: { - globals: { ...globals.node }, - ecmaVersion: 'latest', - sourceType: 'module' - } - }, - { - ignores: ['*.config.*'] } ] diff --git a/ui-tui/package.json b/ui-tui/package.json index d0a59798fb73..7c83ad71e6b3 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -4,14 +4,16 @@ "private": true, "type": "module", "scripts": { - "dev": "npm run build --prefix packages/hermes-ink && tsx --watch src/entry.tsx", + "dev": "npm run build:ink && tsx --watch src/entry.tsx", "start": "tsx src/entry.tsx", "build": "node scripts/build.mjs", + "build:ink": "npm run build --prefix packages/hermes-ink", "typecheck": "tsc --noEmit -p tsconfig.json", "lint": "eslint src/ packages/", "lint:fix": "eslint src/ packages/ --fix", "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'packages/**/*.{ts,tsx}'", "fix": "npm run lint:fix && npm run fmt", + "check": "npm run build:ink && npm run typecheck && npm run test", "test": "vitest run", "test:watch": "vitest" }, @@ -27,7 +29,7 @@ }, "devDependencies": { "@eslint/js": "^9", - "@types/node": "^24.13.2", + "@types/node": "^22.20.0", "@types/react": "^19.2.14", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", diff --git a/ui-tui/packages/hermes-ink/package.json b/ui-tui/packages/hermes-ink/package.json index ab6728a7c9fa..fdee0856741c 100644 --- a/ui-tui/packages/hermes-ink/package.json +++ b/ui-tui/packages/hermes-ink/package.json @@ -4,7 +4,11 @@ "private": true, "type": "module", "scripts": { - "build": "esbuild src/entry-exports.ts --bundle --platform=node --format=esm --packages=external --outdir=dist" + "build": "esbuild src/entry-exports.ts --bundle --platform=node --format=esm --packages=external --outdir=dist", + "check": "npm run typecheck", + "typecheck": "tsc -b . --noEmit", + "lint": "echo 'ok!'", + "fix": "echo 'nothing to fix'" }, "sideEffects": true, "main": "./index.js", @@ -49,6 +53,7 @@ "wrap-ansi": "^9.0.0" }, "devDependencies": { - "esbuild": "^0.28.1" + "esbuild": "^0.28.1", + "typescript": "^6.0.3" } } diff --git a/ui-tui/packages/hermes-ink/tsconfig.json b/ui-tui/packages/hermes-ink/tsconfig.json new file mode 100644 index 000000000000..21601918fe6f --- /dev/null +++ b/ui-tui/packages/hermes-ink/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} \ No newline at end of file diff --git a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts index bf409e95b354..d598f6a834c6 100644 --- a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts +++ b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts @@ -86,10 +86,10 @@ describe('session orchestrator helpers', () => { it('turns model picker values into session-scoped draft model args', () => { expect(draftModelArgFromPickerValue('kimi-k2.6 --provider ollama-cloud --tui-session')).toBe( - 'kimi-k2.6 --provider ollama-cloud' + 'kimi-k2.6 --provider ollama-cloud --session' ) expect(draftModelArgFromPickerValue('openai/gpt-5.5 --provider openai-codex --global')).toBe( - 'openai/gpt-5.5 --provider openai-codex' + 'openai/gpt-5.5 --provider openai-codex --session' ) }) diff --git a/ui-tui/src/__tests__/approvalAction.test.ts b/ui-tui/src/__tests__/approvalAction.test.ts index 662fb71b9605..0c817c644158 100644 --- a/ui-tui/src/__tests__/approvalAction.test.ts +++ b/ui-tui/src/__tests__/approvalAction.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { approvalAction } from '../components/prompts.js' +import { approvalAction, approvalOptions } from '../components/prompts.js' describe('approvalAction — pure key dispatch for ApprovalPrompt', () => { it('maps Esc to deny — parity with global Ctrl+C cancellation', () => { @@ -58,4 +58,23 @@ describe('approvalAction — pure key dispatch for ApprovalPrompt', () => { expect(approvalAction('', { downArrow: true }, 2, opts)).toEqual({ kind: 'noop' }) expect(approvalAction('', { return: true }, 2, opts)).toEqual({ kind: 'choose', choice: 'deny' }) }) + + it('offers only once and deny for Smart DENY owner override', () => { + const opts = approvalOptions({ allowPermanent: true, command: 'rm -rf /', description: 'blocked', smartDenied: true }) + + expect(opts).toEqual(['once', 'deny']) + expect(approvalAction('2', {}, 0, opts)).toEqual({ kind: 'choose', choice: 'deny' }) + expect(approvalAction('3', {}, 0, opts)).toEqual({ kind: 'noop' }) + }) + + it('uses explicit gateway choices as the prompt contract', () => { + expect( + approvalOptions({ + allowPermanent: true, + choices: ['once', 'deny'], + command: 'rm -rf /', + description: 'blocked' + }) + ).toEqual(['once', 'deny']) + }) }) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index fad5f6b4564f..9103877bacc1 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -949,6 +949,23 @@ describe('createGatewayEventHandler', () => { }) }) + it('preserves Smart DENY and explicit approval choices on the overlay', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ + payload: { + allow_permanent: true, + choices: ['once', 'deny'], + command: 'rm -rf /tmp/x', + description: 'smart deny override', + smart_denied: true + }, + type: 'approval.request' + } as any) + + expect(getOverlayState().approval).toMatchObject({ choices: ['once', 'deny'], smartDenied: true }) + }) + it('still surfaces terminal turn failures as errors', () => { const appended: Msg[] = [] const onEvent = createGatewayEventHandler(buildCtx(appended)) @@ -1307,6 +1324,24 @@ describe('createGatewayEventHandler', () => { expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false) }) + it('clears only the matching sensitive prompt when the gateway expires it', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + patchOverlayState({ + secret: { envVar: 'NEW_KEY', prompt: 'Enter new key', requestId: 'secret-new' }, + sudo: { requestId: 'sudo-1' } + }) + + onEvent({ payload: { request_id: 'secret-old' }, type: 'secret.expire' } as any) + expect(getOverlayState().secret?.requestId).toBe('secret-new') + + onEvent({ payload: { request_id: 'secret-new' }, type: 'secret.expire' } as any) + expect(getOverlayState().secret).toBeNull() + + onEvent({ payload: { request_id: 'sudo-1' }, type: 'sudo.expire' } as any) + expect(getOverlayState().sudo).toBeNull() + }) + // ── Credits notice (Strategy B) ────────────────────────────────────── describe('credits notice', () => { it('shows a notice immediately when idle (no turn in flight)', () => { diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index ca1af4cd9abe..e5f97c7e3562 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -209,7 +209,7 @@ describe('createSlashHandler', () => { confirm_expensive_model: false, key: 'model', session_id: 'sid-abc', - value: 'anthropic/claude-sonnet-4.6 --provider openrouter' + value: 'anthropic/claude-sonnet-4.6 --provider openrouter --session' }) }) diff --git a/ui-tui/src/__tests__/cursorDriftRegression.test.ts b/ui-tui/src/__tests__/cursorDriftRegression.test.ts index 3f9082dcefcd..4ab4e7936395 100644 --- a/ui-tui/src/__tests__/cursorDriftRegression.test.ts +++ b/ui-tui/src/__tests__/cursorDriftRegression.test.ts @@ -68,7 +68,7 @@ describe('cursor-drift regression — composer cursorLayout matches Ink renderin ).toEqual(expected) } } - }) + }, 30_000) it('keeps cursor on the same row when text exactly fills the terminal width', () => { // wrap-ansi does NOT push exact-fill text onto a phantom next line. diff --git a/ui-tui/src/__tests__/orchestratorPromptSession.test.ts b/ui-tui/src/__tests__/orchestratorPromptSession.test.ts index f9ff16f34a5c..2d670c4a5b8d 100644 --- a/ui-tui/src/__tests__/orchestratorPromptSession.test.ts +++ b/ui-tui/src/__tests__/orchestratorPromptSession.test.ts @@ -32,7 +32,7 @@ describe('startPromptLiveSession', () => { 'rpc', { method: 'config.set', - params: { key: 'model', session_id: 'abc123', value: 'kimi-k2.6 --provider ollama-cloud' } + params: { key: 'model', session_id: 'abc123', value: 'kimi-k2.6 --provider ollama-cloud --session' } } ], ['sys', 'model → kimi-k2.6'], diff --git a/ui-tui/src/__tests__/statusRule.test.ts b/ui-tui/src/__tests__/statusRule.test.ts index 6af617a973dc..9b8224b40789 100644 --- a/ui-tui/src/__tests__/statusRule.test.ts +++ b/ui-tui/src/__tests__/statusRule.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { busyIndicatorWidth, statusBarSegments, statusRuleWidths } from '../components/appChrome.js' +import { busyIndicatorWidth, StatusBarSegments, statusBarSegments, statusRuleWidths } from '../components/appChrome.js' describe('statusRuleWidths', () => { it('keeps the status rule within the terminal width', () => { @@ -69,8 +69,7 @@ describe('statusBarSegments', () => { voice: true, bg: true, subagents: true, - cost: true - }) + } satisfies StatusBarSegments) }) it('collapses the context bar to a token count on narrow terminals', () => { @@ -79,19 +78,17 @@ describe('statusBarSegments', () => { expect(s.compactCtx).toBe(true) expect(s.bar).toBe(false) expect(s.duration).toBe(false) - expect(s.cost).toBe(false) }) it('sheds tail segments in priority order as the terminal narrows', () => { - // cost is the first to go, the context bar the last of the tail. + // the context bar is the last of the tail to go. const order: (keyof ReturnType)[] = [ 'bar', 'duration', 'compressions', 'voice', 'bg', - 'subagents', - 'cost' + 'subagents' ] let prevCount = Infinity diff --git a/ui-tui/src/__tests__/submissionCore.test.ts b/ui-tui/src/__tests__/submissionCore.test.ts index 83b89a088c84..23b83e87468b 100644 --- a/ui-tui/src/__tests__/submissionCore.test.ts +++ b/ui-tui/src/__tests__/submissionCore.test.ts @@ -38,7 +38,6 @@ function makeDeps(gw: GatewayClient, over: Partial = {}): Subm enqueue: vi.fn(), expand: (t: string) => t, gw, - maybeGoodVibes: vi.fn(), setLastUserMsg: vi.fn(), sys: vi.fn(), ...over diff --git a/ui-tui/src/__tests__/textInputCursorSourceOfTruth.test.ts b/ui-tui/src/__tests__/textInputCursorSourceOfTruth.test.ts index b52894d15878..e6998d9488ef 100644 --- a/ui-tui/src/__tests__/textInputCursorSourceOfTruth.test.ts +++ b/ui-tui/src/__tests__/textInputCursorSourceOfTruth.test.ts @@ -1,13 +1,7 @@ -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - import { describe, expect, it } from 'vitest' -// Locate textInput.tsx relative to this test file so the assertion -// survives moves of the test fixture itself. -const TEXT_INPUT_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'components', 'textInput.tsx') -const source = readFileSync(TEXT_INPUT_PATH, 'utf8') +import { cursorLayout } from '../lib/inputMetrics.js' +import { fastAppendEffect, fastBackspaceEffect, resolveCursorLayout } from '../components/textInput.js' // Closes Copilot follow-up on PR #26717: the original cursor-drift // fix bumped Ink's displayCursor / cursorDeclaration on fast-echo, but @@ -18,33 +12,89 @@ const source = readFileSync(TEXT_INPUT_PATH, 'utf8') // bump. The fix is structural: read `curRef.current` (always // up-to-date) when computing the layout, not the `cur` state. // -// This file pins that invariant. Switching back to `cur` state — or -// re-introducing a memo keyed on `cur` that uses `curRef.current` -// inside but stops re-computing on rerender — is a regression and -// should be caught here, not via a flaky integration test that mounts -// Ink + stdin. -describe('textInput cursor-layout source of truth', () => { - it('reads curRef.current (not the cur React state) for cursorLayout', () => { - // The line we care about. We allow whitespace / formatting drift, - // but the call itself must use `curRef.current`. - expect(source).toMatch(/cursorLayout\(\s*display\s*,\s*curRef\.current\s*,\s*columns\s*\)/) +// These tests exercise the real, exported `resolveCursorLayout`, +// `fastBackspaceEffect`, and `fastAppendEffect` helpers that +// `textInput.tsx` calls at its render site and fast-echo call sites — +// no source-text regex, no readFileSync. +describe('resolveCursorLayout', () => { + it('uses curRefCurrent (the fresh ref value), not the stale cur state', () => { + // Simulate the exact bug scenario: `cur` (React state) is stale — + // it still reflects the value before a fast-echo append — while + // `curRef.current` has already advanced past it. + const display = 'hello world' + const staleCur = 5 + const freshCurRefCurrent = 11 + const columns = 80 + + const result = resolveCursorLayout(display, staleCur, freshCurRefCurrent, columns) + const expected = cursorLayout(display, freshCurRefCurrent, columns) + + expect(result).toEqual(expected) }) - it('does not pass the bare `cur` React state into cursorLayout', () => { - // Any `cursorLayout(display, cur, columns)` invocation would - // reintroduce the stale-declaration window. - expect(source).not.toMatch(/cursorLayout\(\s*display\s*,\s*cur\s*,\s*columns\s*\)/) + it('does not match the layout computed from the stale cur value', () => { + const display = 'hello world' + const staleCur = 5 + const freshCurRefCurrent = 11 + const columns = 80 + + const result = resolveCursorLayout(display, staleCur, freshCurRefCurrent, columns) + const staleLayout = cursorLayout(display, staleCur, columns) + + expect(result).not.toEqual(staleLayout) }) - it('keeps the fast-echo notifier calls paired with the stdout writes', () => { - // Both fast-echo paths must call noteCursorAdvance, otherwise Ink - // never learns about the out-of-band write and drifts again. We - // tolerate explanatory comments in between (the rationale block is - // intentionally long), but the pairing itself must hold. - const backspacePattern = /stdout!\.write\(['"`]\\b \\b['"`]\)[\s\S]{0,1000}?noteCursorAdvance\(-1\)/ - expect(source).toMatch(backspacePattern) + it('matches cursorLayout(display, curRefCurrent, columns) even when cur and curRefCurrent agree', () => { + const display = 'hello' + const cur = 5 + const columns = 80 - const appendPattern = /stdout!\.write\(text\)[\s\S]{0,1000}?noteCursorAdvance\(text\.length\)/ - expect(source).toMatch(appendPattern) + expect(resolveCursorLayout(display, cur, cur, columns)).toEqual(cursorLayout(display, cur, columns)) + }) +}) + +describe('fastBackspaceEffect', () => { + it('removes the last character, moves the cursor back one, and pairs the write with the advance delta', () => { + const effect = fastBackspaceEffect('hello', 5) + + expect(effect.newValue).toBe('hell') + expect(effect.newCursor).toBe(4) + expect(effect.removed).toBe('o') + // Both the stdout write and the noteCursorAdvance delta live on the + // same returned object — a caller cannot apply `write` without also + // having `advanceDelta` in hand, so the pairing can't silently drift. + expect(effect.write).toBe('\b \b') + expect(effect.advanceDelta).toBe(-1) + }) + + it('handles deleting from the middle of the fast-echo-eligible tail', () => { + const effect = fastBackspaceEffect('abc', 3) + + expect(effect.newValue).toBe('ab') + expect(effect.newCursor).toBe(2) + expect(effect.removed).toBe('c') + expect(effect.write).toBe('\b \b') + expect(effect.advanceDelta).toBe(-1) + }) +}) + +describe('fastAppendEffect', () => { + it('appends the text, advances the cursor by the inserted length, and pairs the write with the advance delta', () => { + const effect = fastAppendEffect('hello', 5, ' world') + + expect(effect.newValue).toBe('hello world') + expect(effect.newCursor).toBe(11) + // The stdout write is exactly the inserted text, and the + // noteCursorAdvance delta is bundled into the same object. + expect(effect.write).toBe(' world') + expect(effect.advanceDelta).toBe(' world'.length) + }) + + it('advance delta always matches the inserted text length, not a hardcoded value', () => { + const effect = fastAppendEffect('x', 1, 'abc') + + expect(effect.newValue).toBe('xabc') + expect(effect.advanceDelta).toBe(3) + expect(effect.write).toBe('abc') }) }) diff --git a/ui-tui/src/__tests__/useInputHandlers.test.ts b/ui-tui/src/__tests__/useInputHandlers.test.ts index fa9372d5356e..ef9e676f73e1 100644 --- a/ui-tui/src/__tests__/useInputHandlers.test.ts +++ b/ui-tui/src/__tests__/useInputHandlers.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' +import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js' import { applyVoiceRecordResponse, + dismissSensitivePrompt, handleIdleHotkeyExit, shouldAllowIdleHotkeyExit, shouldFallThroughForScroll @@ -112,3 +114,33 @@ describe('applyVoiceRecordResponse', () => { expect(setProcessing).toHaveBeenCalledWith(false) }) }) + +describe('dismissSensitivePrompt', () => { + it('clears a sudo overlay before a stale cancel RPC resolves', async () => { + resetOverlayState() + patchOverlayState({ sudo: { requestId: 'sudo-1' } }) + const rpc = vi.fn().mockResolvedValue(null) + const sys = vi.fn() + + const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys) + + expect(getOverlayState().sudo).toBeNull() + expect(sys).toHaveBeenCalledWith('sudo cancelled') + expect(rpc).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' }) + await pending + }) + + it('clears a secret overlay before a stale cancel RPC resolves', async () => { + resetOverlayState() + patchOverlayState({ secret: { envVar: 'API_KEY', prompt: 'Enter API key', requestId: 'secret-1' } }) + const rpc = vi.fn().mockResolvedValue(null) + const sys = vi.fn() + + const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys) + + expect(getOverlayState().secret).toBeNull() + expect(sys).toHaveBeenCalledWith('secret entry cancelled') + expect(rpc).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' }) + await pending + }) +}) diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts index 9819a7214da9..17cd32fec8d3 100644 --- a/ui-tui/src/__tests__/virtualHeights.test.ts +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -18,10 +18,13 @@ describe('virtual height estimates', () => { }) it('uses compound user prompt width when estimating user message wrapping', () => { - const msg: Msg = { role: 'user', text: 'x'.repeat(21) } + // cols must clear the 20-col body-width floor for both prompts (gutter + + // horizontalReserve=4) so the wider 'Ψ >' prompt actually narrows the + // body enough to wrap an extra line vs the single-cell '❯' prompt. + const msg: Msg = { role: 'user', text: 'x'.repeat(23) } - expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: '❯' })).toBe(3) - expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: 'Ψ >' })).toBe(4) + expect(estimatedMsgHeight(msg, 30, { compact: false, details: false, userPrompt: '❯' })).toBe(3) + expect(estimatedMsgHeight(msg, 30, { compact: false, details: false, userPrompt: 'Ψ >' })).toBe(4) }) it('adds one row for a group-boundary lead gap', () => { diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index d9cbf30663ea..051f09347777 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -20,7 +20,7 @@ import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' import type { GatewayEventHandlerContext } from './interfaces.js' import { getOverlayState, patchOverlayState } from './overlayStore.js' -import { flashPet } from './petFlashStore.js' +import { flashGoodVibes, flashPet } from './petFlashStore.js' import { turnController } from './turnController.js' import { getTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' @@ -717,6 +717,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return + case 'reaction': + // Core-detected affection (ily / <3 / good bot): flash the ♥ and let the + // pet celebrate. Same signal drives the desktop's floating hearts. + flashGoodVibes() + flashPet('jump') + + return + case 'tool.start': turnController.recordTodos(ev.payload.todos) turnController.recordToolStart( @@ -778,7 +786,13 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const allowPermanent = ev.payload.allow_permanent !== false patchOverlayState({ - approval: { allowPermanent, command: String(ev.payload.command ?? ''), description } + approval: { + allowPermanent, + choices: ev.payload.choices, + command: String(ev.payload.command ?? ''), + description, + smartDenied: ev.payload.smart_denied === true + } }) setStatus('approval needed') @@ -799,6 +813,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return + case 'sudo.expire': + patchOverlayState(prev => (prev.sudo?.requestId === ev.payload.request_id ? { ...prev, sudo: null } : prev)) + + return + + case 'secret.expire': + patchOverlayState(prev => (prev.secret?.requestId === ev.payload.request_id ? { ...prev, secret: null } : prev)) + + return + case 'background.complete': dropBgTask(ev.payload.task_id) sys(`[bg ${ev.payload.task_id}] ${ev.payload.text}`) diff --git a/ui-tui/src/app/petFlashStore.ts b/ui-tui/src/app/petFlashStore.ts index b1ecf97e9953..48841a3c4be6 100644 --- a/ui-tui/src/app/petFlashStore.ts +++ b/ui-tui/src/app/petFlashStore.ts @@ -14,6 +14,13 @@ export const $petFlash = atom(null) export const flashPet = (state: PetState, ms = 1600) => $petFlash.set({ state, until: Date.now() + ms }) +// Affection-heart beat: a monotonic tick the status-bar ♥ flashes on. Bumped by +// the gateway `reaction` event (core-detected ily / <3 / good bot) — the TUI's +// share of the same signal that plays the desktop's floating hearts. +export const $goodVibesTick = atom(0) + +export const flashGoodVibes = () => $goodVibesTick.set($goodVibesTick.get() + 1) + // The floating pet's footprint, or null when no pet is shown. The transcript // keeps its text clear of the pet responsively: on wide terminals it reserves a // right gutter (`width`) so lines wrap to the pet's LEFT; on narrow terminals it diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 6b1fe55481bc..ce1c4ead945d 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -1,5 +1,5 @@ import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js' -import { TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js' +import { sessionScopedModelArg, TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js' import type { BackgroundStartResponse, ConfigGetValueResponse, @@ -20,10 +20,6 @@ import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`) -const TUI_SESSION_STRIP_RE = new RegExp(`\\s*${TUI_SESSION_MODEL_FLAG}\\b\\s*`, 'g') - -const stripTuiSessionFlag = (trimmed: string) => trimmed.replace(TUI_SESSION_STRIP_RE, ' ').replace(/\s+/g, ' ').trim() - const modelValueForConfigSet = (arg: string) => { const trimmed = arg.trim() @@ -32,7 +28,7 @@ const modelValueForConfigSet = (arg: string) => { } if (TUI_SESSION_MODEL_RE.test(trimmed)) { - return stripTuiSessionFlag(trimmed) + return sessionScopedModelArg(trimmed) } return trimmed diff --git a/ui-tui/src/app/submissionCore.ts b/ui-tui/src/app/submissionCore.ts index 7c561b745ba7..0ec3921bb3fe 100644 --- a/ui-tui/src/app/submissionCore.ts +++ b/ui-tui/src/app/submissionCore.ts @@ -15,7 +15,6 @@ export interface SubmitPromptDeps { enqueue: (text: string) => void expand: (text: string) => string gw: GatewayClient - maybeGoodVibes: (text: string) => void setLastUserMsg: (value: string) => void sys: (text: string) => void } @@ -61,7 +60,6 @@ export function submitPrompt(text: string, deps: SubmitPromptDeps, showUserMessa } turnController.clearStatusTimer() - deps.maybeGoodVibes(submitText) deps.setLastUserMsg(text) if (show) { diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 2f95c565e85a..3461cd241a88 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -16,7 +16,13 @@ import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionW import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js' import { getInputSelection } from './inputSelectionStore.js' -import type { InputHandlerActions, InputHandlerContext, InputHandlerResult } from './interfaces.js' +import type { + GatewayRpc, + InputHandlerActions, + InputHandlerContext, + InputHandlerResult, + OverlayState +} from './interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' import { patchTurnState } from './turnStore.js' @@ -97,6 +103,30 @@ export function applyVoiceRecordResponse( } } +export function dismissSensitivePrompt( + overlay: Pick, + rpc: GatewayRpc, + sys: (text: string) => void +) { + if (overlay.sudo) { + const requestId = overlay.sudo.requestId + + patchOverlayState({ sudo: null }) + sys('sudo cancelled') + + return rpc('sudo.respond', { password: '', request_id: requestId }) + } + + if (overlay.secret) { + const requestId = overlay.secret.requestId + + patchOverlayState({ secret: null }) + sys('secret entry cancelled') + + return rpc('secret.respond', { request_id: requestId, value: '' }) + } +} + export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const { actions, composer, gateway, terminal, voice, wheelStep } = ctx const { actions: cActions, refs: cRefs, state: cState } = composer @@ -149,16 +179,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { .then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' }))) } - if (overlay.sudo) { - return gateway - .rpc('sudo.respond', { password: '', request_id: overlay.sudo.requestId }) - .then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled'))) - } - - if (overlay.secret) { - return gateway - .rpc('secret.respond', { request_id: overlay.secret.requestId, value: '' }) - .then(r => r && (patchOverlayState({ secret: null }), actions.sys('secret entry cancelled'))) + if (overlay.sudo || overlay.secret) { + return dismissSensitivePrompt(overlay, gateway.rpc, actions.sys) } if (overlay.modelPicker) { @@ -373,7 +395,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } - if (isCtrl(key, ch, 'c')) { + if (isCtrl(key, ch, 'c') || (key.escape && (overlay.secret || overlay.sudo))) { cancelOverlayFromCtrlC() } else if (key.escape && overlay.sessions) { patchOverlayState({ sessions: false }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 33ab9b2f3e32..a7b5e2d2e4ee 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -9,6 +9,7 @@ import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js' import { SECTION_NAMES, sectionMode } from '../domain/details.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js' +import { sessionScopedModelArg } from '../domain/slash.js' import { type GatewayClient } from '../gatewayClient.js' import type { ClarifyRespondResponse, @@ -37,6 +38,7 @@ import { planGatewayRecovery } from './gatewayRecovery.js' import { getInputSelection } from './inputSelectionStore.js' import { type GatewayRpc, type TranscriptRow } from './interfaces.js' import { $overlayState, patchOverlayState } from './overlayStore.js' +import { $goodVibesTick } from './petFlashStore.js' import { scrollWithSelectionBy } from './scroll.js' import { turnController } from './turnController.js' import { patchTurnState, useTurnSelector } from './turnStore.js' @@ -48,7 +50,6 @@ import { useLongRunToolCharms } from './useLongRunToolCharms.js' import { useSessionLifecycle } from './useSessionLifecycle.js' import { useSubmission } from './useSubmission.js' -const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i const BRACKET_PASTE_ON = '\x1b[?2004h' const BRACKET_PASTE_OFF = '\x1b[?2004l' const MAX_HEIGHT_CACHE_BUCKETS = 12 @@ -116,7 +117,7 @@ export async function startPromptLiveSession({ return null } - const requestedModel = modelArg?.trim() + const requestedModel = modelArg ? sessionScopedModelArg(modelArg) : '' if (requestedModel) { const result = await rpc('config.set', { key: 'model', session_id: sid, value: requestedModel }) @@ -185,7 +186,8 @@ export function useMainApp(gw: GatewayClient) { const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [turnStartedAt, setTurnStartedAt] = useState(null) const [lastTurnEndedAt, setLastTurnEndedAt] = useState(null) - const [goodVibesTick, setGoodVibesTick] = useState(0) + // Bumped by the gateway `reaction` event (core-detected affection). + const goodVibesTick = useStore($goodVibesTick) const [bellOnComplete, setBellOnComplete] = useState(false) const ui = useStore($uiState) @@ -445,12 +447,6 @@ export function useMainApp(gw: GatewayClient) { [sys] ) - const maybeGoodVibes = useCallback((text: string) => { - if (GOOD_VIBES_RE.test(text)) { - setGoodVibesTick(v => v + 1) - } - }, []) - const rpc: GatewayRpc = useCallback( async = Record>( method: string, @@ -690,7 +686,6 @@ export function useMainApp(gw: GatewayClient) { composerRefs, composerState, gw, - maybeGoodVibes, setLastUserMsg, slashRef, submitRef, @@ -916,7 +911,13 @@ export function useMainApp(gw: GatewayClient) { return } - return respondWith('sudo.respond', { password: pw, request_id: overlay.sudo.requestId }, () => { + const requestId = overlay.sudo.requestId + + if (!pw) { + patchOverlayState({ sudo: null }) + } + + return respondWith('sudo.respond', { password: pw, request_id: requestId }, () => { patchOverlayState({ sudo: null }) patchUiState({ status: 'running…' }) }) @@ -930,7 +931,13 @@ export function useMainApp(gw: GatewayClient) { return } - return respondWith('secret.respond', { request_id: overlay.secret.requestId, value }, () => { + const requestId = overlay.secret.requestId + + if (!value) { + patchOverlayState({ secret: null }) + } + + return respondWith('secret.respond', { request_id: requestId, value }, () => { patchOverlayState({ secret: null }) patchUiState({ status: 'running…' }) }) diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 6ece0bf64125..6e02d349a1dc 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -37,7 +37,6 @@ export function useSubmission(opts: UseSubmissionOptions) { composerRefs, composerState, gw, - maybeGoodVibes, setLastUserMsg, slashRef, submitRef, @@ -87,14 +86,13 @@ export function useSubmission(opts: UseSubmissionOptions) { enqueue: composerActions.enqueue, expand, gw, - maybeGoodVibes, setLastUserMsg, sys }, showUserMessage ) }, - [appendMessage, composerActions, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys] + [appendMessage, composerActions, composerState.pasteSnips, gw, setLastUserMsg, sys] ) const shellExec = useCallback( @@ -362,7 +360,6 @@ export interface UseSubmissionOptions { composerRefs: ComposerRefs composerState: ComposerState gw: GatewayClient - maybeGoodVibes: (text: string) => void setLastUserMsg: (value: string) => void slashRef: MutableRefObject<(cmd: string) => boolean> submitRef: MutableRefObject<(value: string) => void> diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx index af4fbbb27fb1..4ac3385420c1 100644 --- a/ui-tui/src/components/activeSessionSwitcher.tsx +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -1,7 +1,7 @@ import { Box, Text, useInput, useStdout } from '@hermes/ink' import { useCallback, useEffect, useRef, useState } from 'react' -import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' +import { sessionScopedModelArg } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' import type { SessionActiveItem, @@ -205,18 +205,7 @@ export const closeFallbackAfterClose = ( } export const draftModelArgFromPickerValue = (value: string) => { - const parts = value.trim().split(/\s+/).filter(Boolean) - const kept: string[] = [] - - for (const part of parts) { - if (part === TUI_SESSION_MODEL_FLAG || part === '--global') { - continue - } - - kept.push(part) - } - - return kept.join(' ') + return sessionScopedModelArg(value) } export const draftModelNameFromArg = (value: string) => { diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index acac12eef18a..bfd7bf62d278 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -10,11 +10,24 @@ import { TextInput } from './textInput.js' const APPROVAL_OPTS = ['once', 'session', 'always', 'deny'] as const // tirith warning present → backend downgrades "always" to session scope, so drop it. const APPROVAL_OPTS_NO_ALWAYS = APPROVAL_OPTS.filter(o => o !== 'always') +const APPROVAL_OPTS_SMART_DENY = ['once', 'deny'] as const const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const const CMD_PREVIEW_LINES = 10 type ApprovalChoice = 'always' | 'deny' | 'once' | 'session' +export function approvalOptions(req: ApprovalReq): readonly ApprovalChoice[] { + if (req.choices) { + return req.choices.filter((choice): choice is ApprovalChoice => APPROVAL_OPTS.includes(choice as ApprovalChoice)) + } + + if (req.smartDenied) { + return APPROVAL_OPTS_SMART_DENY + } + + return req.allowPermanent === false ? APPROVAL_OPTS_NO_ALWAYS : APPROVAL_OPTS +} + type ApprovalKey = { downArrow?: boolean escape?: boolean @@ -68,7 +81,7 @@ export function approvalAction( export function ApprovalPrompt({ cols = 80, onChoice, req, t }: ApprovalPromptProps) { const [sel, setSel] = useState(0) - const opts = req.allowPermanent === false ? APPROVAL_OPTS_NO_ALWAYS : APPROVAL_OPTS + const opts = approvalOptions(req) useInput((ch, key) => { const action = approvalAction(ch, key, sel, opts) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 9cbebd416f48..9fed82096d25 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -264,6 +264,81 @@ const ASCII_PRINTABLE_RE = /^[\x20-\x7e]+$/ * length 1 but are still produced by IME compositions and must not be * fast-echoed. */ +/** + * Resolves which cursor position `cursorLayout` should be computed from. + * + * The fast-echo path defers the React `setCur` by 16ms to batch + * re-renders during heavy typing. If an unrelated render flushes this + * component during that window and the layout used the stale `cur` + * React state, the layout effect inside `useDeclaredCursor` would + * publish a stale cursor declaration and clobber the Ink-level bump + * from `noteCursorAdvance(...)` (the cursor-drift regression closed by + * PR #26717's Copilot follow-up). `curRef.current` is always + * up-to-date, so it — never the possibly-stale `cur` state — must be + * the source of truth here. + * + * Extracted as a pure function (rather than inlining `curRef.current` + * directly at the call site) so the invariant is unit-testable without + * mounting Ink/React: construct a scenario where `cur` and + * `curRefCurrent` genuinely diverge and assert the layout matches the + * fresh ref value, not the stale state. + */ +export function resolveCursorLayout(display: string, cur: number, curRefCurrent: number, columns: number) { + void cur // intentionally unused for layout — see doc comment above + + return cursorLayout(display, curRefCurrent, columns) +} + +/** + * Pure computation for the fast-echo backspace bypass: given the + * current value/cursor (already validated by `canFastBackspaceShape`), + * returns what the new value/cursor should be, the exact stdout write + * ("\b \b"), and the delta to report to Ink's `noteCursorAdvance`. + * + * Bundling the write + notifier delta into a single return value means + * the "every fast-echo write must be paired with a matching + * noteCursorAdvance call" invariant is enforced by the return shape + * itself (a caller can't apply `write` without also having + * `advanceDelta` in hand) rather than by two independent call sites + * that happen to sit near each other in source. + */ +export function fastBackspaceEffect( + current: string, + cursor: number +): { advanceDelta: number; newCursor: number; newValue: string; removed: string; write: string } { + const t = prevPos(current, cursor) + const removed = current.slice(t, cursor) + + return { + advanceDelta: -1, + newCursor: t, + newValue: current.slice(0, t) + current.slice(cursor), + removed, + write: '\b \b' + } +} + +/** + * Pure computation for the fast-echo append bypass: given the current + * value/cursor (already validated by `canFastAppendShape`) and the + * inserted text, returns the new value/cursor, the exact stdout write + * (the inserted text itself), and the delta to report to Ink's + * `noteCursorAdvance`. See `fastBackspaceEffect` for why write + delta + * are bundled into one return value. + */ +export function fastAppendEffect( + current: string, + cursor: number, + text: string +): { advanceDelta: number; newCursor: number; newValue: string; write: string } { + return { + advanceDelta: text.length, + newCursor: cursor + text.length, + newValue: current.slice(0, cursor) + text + current.slice(cursor), + write: text + } +} + export function canFastAppendShape( current: string, cursor: number, @@ -517,7 +592,7 @@ export function TextInput({ // for layout. The cursorLayout call is cheap (one wrap-text pass // over a single-line string in the common case), so dropping useMemo // is fine. - const layout = cursorLayout(display, curRef.current, columns) + const layout = resolveCursorLayout(display, cur, curRef.current, columns) const boxRef = useDeclaredCursor({ line: layout.line, @@ -1080,16 +1155,16 @@ export function TextInput({ v = v.slice(0, t) + v.slice(c) c = t } else if (canFastBackspace(v, c)) { - const t = prevPos(v, c) - v = v.slice(0, t) + v.slice(c) - c = t - stdout!.write('\b \b') + const effect = fastBackspaceEffect(v, c) + v = effect.newValue + c = effect.newCursor + stdout!.write(effect.write) // The "\b \b" sequence ends with the cursor one column to the // LEFT of where Ink last parked it. Tell Ink so its `displayCursor` // (and log-update's relative-move basis on the next frame) stays // in sync — otherwise the cursor parks one cell to the right of // the caret on the next unrelated re-render. - noteCursorAdvance(-1) + noteCursorAdvance(effect.advanceDelta) commit(v, c, true, false, false, Math.max(0, lineWidthRef.current - 1)) return @@ -1184,12 +1259,15 @@ export function TextInput({ c = inserted.cursor } else { const simpleAppend = canFastAppend(v, c, text) + const preInsertValue = v + const preInsertCursor = c v = inserted.value c = inserted.cursor if (simpleAppend) { - stdout!.write(text) + const effect = fastAppendEffect(preInsertValue, preInsertCursor, text) + stdout!.write(effect.write) // ASCII-printable text advances the physical cursor by exactly // text.length cells (canFastAppendShape rejects non-ASCII, // wide chars, newlines). Notify Ink so the cached displayCursor @@ -1197,7 +1275,7 @@ export function TextInput({ // any unrelated re-render that happens before the 16ms // setCur/setParent flush parks the cursor text.length cells // too far right (#cursor-drift). - noteCursorAdvance(text.length) + noteCursorAdvance(effect.advanceDelta) commit(v, c, true, false, false, lineWidthRef.current + stringWidth(text)) return diff --git a/ui-tui/src/domain/slash.ts b/ui-tui/src/domain/slash.ts index 42962ae69d4c..b86c34d134cc 100644 --- a/ui-tui/src/domain/slash.ts +++ b/ui-tui/src/domain/slash.ts @@ -1,6 +1,13 @@ -/** Appended to `/model` args from the TUI picker for session scope; stripped in `session` slash before `config.set`. */ +/** Appended by TUI pickers; converted to the backend's `--session` flag before `config.set`. */ export const TUI_SESSION_MODEL_FLAG = '--tui-session' +export const sessionScopedModelArg = (value: string) => { + const parts = value.trim().split(/\s+/).filter(Boolean) + const kept = parts.filter(part => part !== TUI_SESSION_MODEL_FLAG && part !== '--global' && part !== '--session') + + return kept.length ? `${kept.join(' ')} --session` : '' +} + export const looksLikeSlashCommand = (text: string) => /^\/[^\s/]*(?:\s|$)/.test(text) export const parseSlashCommand = (cmd: string) => { diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index ee6b8d78c451..46e3019fde38 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -618,6 +618,7 @@ export type GatewayEvent = | { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' } | { payload: SessionInfo; session_id?: string; type: 'session.info' } | { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' } + | { payload?: { kind?: string }; session_id?: string; type: 'reaction' } | { payload?: undefined; session_id?: string; type: 'message.start' } | { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' } | { @@ -691,12 +692,13 @@ export type GatewayEvent = type: 'clarify.request' } | { - payload: { allow_permanent?: boolean; command: string; description: string } + payload: { allow_permanent?: boolean; choices?: string[]; command: string; description: string; smart_denied?: boolean } session_id?: string type: 'approval.request' } | { payload: { request_id: string }; session_id?: string; type: 'sudo.request' } | { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' } + | { payload: { request_id: string }; session_id?: string; type: 'secret.expire' | 'sudo.expire' } | { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' } | { payload?: { text?: string }; session_id?: string; type: 'review.summary' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' } diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 14ab98ca18dd..6f6818e37cde 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -92,8 +92,10 @@ export interface DelegationStatus { export interface ApprovalReq { // false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow". allowPermanent?: boolean + choices?: string[] command: string description: string + smartDenied?: boolean } export interface ConfirmReq { diff --git a/uv.lock b/uv.lock index f70435a95060..7dd892579d12 100644 --- a/uv.lock +++ b/uv.lock @@ -1794,7 +1794,7 @@ requires-dist = [ { name = "honcho-ai", marker = "extra == 'honcho'", specifier = "==2.0.1" }, { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "jinja2", specifier = "==3.1.6" }, - { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.5.3" }, + { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.6.8" }, { name = "markdown", specifier = "==3.10.2" }, { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = "==0.21.0" }, { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" }, @@ -1804,7 +1804,7 @@ requires-dist = [ { name = "microsoft-teams-apps", marker = "extra == 'teams'", specifier = "==2.0.13.4" }, { name = "mistralai", marker = "extra == 'mistral'", specifier = "==2.4.8" }, { name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" }, - { name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = "==0.3" }, + { name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = ">=0.5,<1.0" }, { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, { name = "openai", specifier = "==2.24.0" }, { name = "packaging", specifier = "==26.0" }, @@ -1834,10 +1834,10 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" }, { name = "setuptools", marker = "extra == 'dev'", specifier = "==81.0.0" }, { name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" }, - { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" }, - { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" }, - { name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.40.1" }, - { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.40.1" }, + { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.29.0" }, + { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.29.0" }, + { name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.43.0" }, + { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.43.0" }, { name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" }, { name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.0.1" }, { name = "starlette", marker = "extra == 'dev'", specifier = "==1.0.1" }, @@ -2184,7 +2184,7 @@ wheels = [ [[package]] name = "lark-oapi" -version = "1.5.3" +version = "1.6.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2194,7 +2194,7 @@ dependencies = [ { name = "websockets" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36", size = 6993016, upload-time = "2026-01-27T08:21:49.307Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ad/1ab04db5d549ad1a7a2cd33682b1c38dee1d65019cb24fd7a23270e6337d/lark_oapi-1.6.8-py3-none-any.whl", hash = "sha256:9b443a5d47a7d204dd42dc40896c8b75087cc35788e45c48c140806d7df7e5e8", size = 7798300, upload-time = "2026-06-02T07:40:05.492Z" }, ] [[package]] @@ -2608,14 +2608,14 @@ wheels = [ [[package]] name = "nemo-relay" -version = "0.3.0" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/55/3b65643db2df02fb3838b3d251442e05b7306cbbc77e69884ae78743546f/nemo_relay-0.3.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:a6e44abe38bf6dda8f6a8b149cd9069a548186f24263ae8469d5a37cefc95209", size = 5282297, upload-time = "2026-05-29T22:32:36.315Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/e2f4bc02f99f38706fdde0cdda6b6d8fddea9e6975da1b66377c35958b85/nemo_relay-0.3.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99ba247121cd0d17b9c5e2a95beb42512243430773b93435848b8b4b9f9ca61f", size = 4722431, upload-time = "2026-05-29T22:32:38.088Z" }, - { url = "https://files.pythonhosted.org/packages/12/18/bedb5d57f206e2859cef11f12f568974a529acf382436523a37e095b2cc7/nemo_relay-0.3.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20bb1e4ed87f179befc4db3af2e35f08be711378b322f3672222d80294f57be7", size = 5002734, upload-time = "2026-05-29T22:32:40.191Z" }, - { url = "https://files.pythonhosted.org/packages/26/ec/4b2e758a0a25397f2e97be889a11b7787d108658e0b60ddff5048b25adb8/nemo_relay-0.3.0-cp311-abi3-win_amd64.whl", hash = "sha256:e659c2772b35c0ea4897a91f6bf86b551ed403f6f41d0b60fd8151f5c78b83d0", size = 4947785, upload-time = "2026-05-29T22:32:41.784Z" }, - { url = "https://files.pythonhosted.org/packages/2b/91/45a3397559679d846ae023a82527c43b14631786b3a7c95e69c05e8bb553/nemo_relay-0.3.0-cp311-abi3-win_arm64.whl", hash = "sha256:9655514adb518e19caf7b3f6a08bbe82c1b24759c0a8021c3df949fd265bcef6", size = 4662147, upload-time = "2026-05-29T22:32:43.554Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fc/6892859ffbfb60f8cc09a4181c8d407b574d49490e35cd563e7f0a61bf54/nemo_relay-0.5.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:203409e0f392fc4f40f52277347ed75ba9969b2af28ddb3b6ae53f789185a0ed", size = 7769721, upload-time = "2026-07-08T18:43:57.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/13/25c4fcf6fb979af07c9562238819183588a910c2ae09767603500d6948c2/nemo_relay-0.5.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de61ad0644c5e8c300de814f6884c5de7ae51db14694f5057f938b4da54bfaaf", size = 7037083, upload-time = "2026-07-08T18:44:00.135Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/bd4c77e975d50f7d09f8bfcb74d7e704e05a8b2205f555bf557f84edc5fe/nemo_relay-0.5.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb323569f104506f4f1a0e2104b22d74c9a0bbf3280ec4d58ad3d768de76fb15", size = 7430817, upload-time = "2026-07-08T18:44:02.165Z" }, + { url = "https://files.pythonhosted.org/packages/da/8b/5681f3430e6d25bf7419dbe576da14599e1cfadc083edb74e691a0c3a980/nemo_relay-0.5.0-cp311-abi3-win_amd64.whl", hash = "sha256:da1bd1e1dec79b7bda3984dd8c59b32ba97abf96c37e85cd1f38d208a29f42b9", size = 7359048, upload-time = "2026-07-08T18:44:04.169Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f2/65c3ef0435e671c829063d4872897f9054f369f0aeacab12c5aefe486705/nemo_relay-0.5.0-cp311-abi3-win_arm64.whl", hash = "sha256:87462585f17b40ce82c54347acef5ace96ee11de3015f234e0b902f23b3793fb", size = 6934235, upload-time = "2026-07-08T18:44:06.078Z" }, ] [[package]] @@ -3889,23 +3889,23 @@ wheels = [ [[package]] name = "slack-bolt" -version = "1.27.0" +version = "1.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "slack-sdk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/28/50ed0b86e48b48e6ddcc71de93b91c8ac14a55d1249e4bff0586494a2f90/slack_bolt-1.27.0.tar.gz", hash = "sha256:3db91d64e277e176a565c574ae82748aa8554f19e41a4fceadca4d65374ce1e0", size = 129101, upload-time = "2025-11-13T20:17:46.878Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/2c/7434f5d8c52eafb1e702012e9f291b013bd05985a5b32bde568b2279a28a/slack_bolt-1.29.0.tar.gz", hash = "sha256:b6271ba0a9b71e319c86b40632e6cb6240aacd0433773615b76b890b9a574762", size = 131151, upload-time = "2026-06-30T20:24:44.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/a8/1acb355759747ba4da5f45c1a33d641994b9e04b914908c9434f18bd97e8/slack_bolt-1.27.0-py2.py3-none-any.whl", hash = "sha256:c43c94bf34740f2adeb9b55566c83f1e73fed6ba2878bd346cdfd6fd8ad22360", size = 230428, upload-time = "2025-11-13T20:17:45.465Z" }, + { url = "https://files.pythonhosted.org/packages/2f/cc/e5f78a80a1775a4cbb2cc41e8ef433602d5e755ff0d829bd43145015740a/slack_bolt-1.29.0-py2.py3-none-any.whl", hash = "sha256:1835b66b778158f3af0da77603aa18d7dfd82fd9b9a985e25c752f95050ab826", size = 235345, upload-time = "2026-06-30T20:24:42.558Z" }, ] [[package]] name = "slack-sdk" -version = "3.40.1" +version = "3.43.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/18/784859b33a3f9c8cdaa1eda4115eb9fe72a0a37304718887d12991eeb2fd/slack_sdk-3.40.1.tar.gz", hash = "sha256:a215333bc251bc90abf5f5110899497bf61a3b5184b6d9ee35d73ebf09ec3fd0", size = 250379, upload-time = "2026-02-18T22:11:01.819Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07", size = 252769, upload-time = "2026-06-30T18:04:41.59Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/e1/bb81f93c9f403e3b573c429dd4838ec9b44e4ef35f3b0759eb49557ab6e3/slack_sdk-3.40.1-py2.py3-none-any.whl", hash = "sha256:cd8902252979aa248092b0d77f3a9ea3cc605bc5d53663ad728e892e26e14a65", size = 313687, upload-time = "2026-02-18T22:11:00.027Z" }, + { url = "https://files.pythonhosted.org/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585", size = 315866, upload-time = "2026-06-30T18:04:39.636Z" }, ] [[package]] diff --git a/web/eslint.config.js b/web/eslint.config.js index 5e6b472f583e..aba2670175ec 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -19,5 +19,18 @@ export default defineConfig([ ecmaVersion: 2020, globals: globals.browser, }, + rules: { + // Context providers and hook files commonly export both a component + // (the Provider) and a hook (useContext). Allow constant exports so + // these don't need to be split into separate files. + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + // TODO: upgrade these react-hooks v7 rules from 'warn' to 'error' after + // refactoring set-state-in-effect, ref-as-instance-var, and manual + // memoization patterns in the web codebase. + 'react-hooks/set-state-in-effect': 'warn', + 'react-hooks/refs': 'warn', + 'react-hooks/preserve-manual-memoization': 'warn', + 'react-hooks/static-components': 'warn', + }, }, ]) diff --git a/web/package.json b/web/package.json index 0e9987b51b38..43ac160fe1d9 100644 --- a/web/package.json +++ b/web/package.json @@ -7,9 +7,12 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "lint:fix": "eslint . --fix", + "fix": "npm run lint:fix", "preview": "vite preview", "typecheck": "tsc -p . --noEmit", - "test": "vitest run" + "test": "vitest run", + "check": "npm run typecheck && npm run test" }, "dependencies": { "@hermes/shared": "file:../apps/shared", @@ -38,7 +41,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", - "@types/node": "^24.13.2", + "@types/node": "^22.20.0", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 47cb5e72b052..57236e782bc8 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -79,6 +79,21 @@ interface ChatSidebarProps { onSessionTitleChange?: (title: string | null) => void; } +/** Build the ``session.create`` params for the sidecar session. + * + * Extracted from the effect below so the invariant — close_on_disconnect + * is set, source is "tool", and the profile is forwarded when present — + * can be tested without reading component source text. See + * ``chat-sidebar-session-params.test.ts``. + */ +export function sidecarSessionCreateParams(profile?: string): Record { + return { + close_on_disconnect: true, + source: "tool", + ...(profile ? { profile } : {}), + }; +} + export function ChatSidebar({ channel, profile, @@ -189,11 +204,7 @@ export function ChatSidebar({ } // close_on_disconnect: the gateway reaps this sidecar session (and its // slash_worker subprocess) when the WS drops, instead of leaking it. - return gw.request<{ session_id: string }>("session.create", { - close_on_disconnect: true, - source: "tool", - ...(profile ? { profile } : {}), - }); + return gw.request<{ session_id: string }>("session.create", sidecarSessionCreateParams(profile)); }) .catch((e: Error) => { if (!cancelled) { @@ -382,7 +393,7 @@ export function ChatSidebar({ onClick={reconnect} prefix={} > - reconnect + reconnect tools feed )} diff --git a/web/src/components/OAuthLoginModal.tsx b/web/src/components/OAuthLoginModal.tsx index f35fd81575b7..dd815c2ee073 100644 --- a/web/src/components/OAuthLoginModal.tsx +++ b/web/src/components/OAuthLoginModal.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from "react"; -import { ExternalLink, X, Check } from "lucide-react"; +import { ExternalLink, X, Check, Copy } from "lucide-react"; import { Button } from "@nous-research/ui/ui/components/button"; -import { CopyButton } from "@nous-research/ui/ui/components/command-block"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { Input } from "@nous-research/ui/ui/components/input"; import { useI18n } from "@/i18n"; import { cn, themedBody } from "@/lib/utils"; @@ -30,9 +30,13 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) { const [start, setStart] = useState(null); const [pkceCode, setPkceCode] = useState(""); const [errorMsg, setErrorMsg] = useState(null); + const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">( + "idle", + ); const [secondsLeft, setSecondsLeft] = useState(null); const isMounted = useRef(true); const pollTimer = useRef(null); + const copyResetTimer = useRef(null); const { t } = useI18n(); // Initiate flow on mount @@ -59,6 +63,8 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) { return () => { isMounted.current = false; if (pollTimer.current !== null) window.clearInterval(pollTimer.current); + if (copyResetTimer.current !== null) + window.clearTimeout(copyResetTimer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -162,6 +168,24 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) { return `${m}:${String(r).padStart(2, "0")}`; }; + const handleCopyDeviceCode = async (code: string) => { + if (copyResetTimer.current !== null) { + window.clearTimeout(copyResetTimer.current); + copyResetTimer.current = null; + } + const copied = await copyTextToClipboard(code); + if (!isMounted.current) return; + setCopyStatus(copied ? "copied" : "failed"); + copyResetTimer.current = window.setTimeout(() => { + if (isMounted.current) setCopyStatus("idle"); + copyResetTimer.current = null; + }, 2000); + }; + + const deviceCode = start?.flow === "device_code" ? start.user_code : ""; + const verificationUrl = + start?.flow === "device_code" ? start.verification_url : ""; + return (
- { - ( - start as Extract< - OAuthStartResponse, - { flow: "device_code" } - > - ).user_code - } + {deviceCode} - - ).user_code +
+ {copyStatus === "failed" && ( +

+ {t.oauth.copyFailed} +

+ )} - ).verification_url - } + href={verificationUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" diff --git a/web/src/i18n/af.ts b/web/src/i18n/af.ts index 63d7342c7074..8a7c8a1b96e6 100644 --- a/web/src/i18n/af.ts +++ b/web/src/i18n/af.ts @@ -446,16 +446,19 @@ export const af: Translations = { oauth: { title: "Verskaffer-aanmeldings (OAuth)", providerLogins: "Verskaffer-aanmeldings (OAuth)", - description: "{connected} van {total} OAuth-verskaffers gekoppel. Aanmeldvloei loop tans via die CLI; klik Kopieer opdrag en plak in 'n terminaal om op te stel.", + description: + "{connected} van {total} OAuth-verskaffers gekoppel. Gebruik Meld aan vir vloeie wat die kontroleskerm ondersteun; CLI-opdragte bly beskikbaar vir eksterne of terugval-opstelling.", connected: "Gekoppel", expired: "Verval", - notConnected: "Nie gekoppel nie. Voer {command} uit in 'n terminaal.", + notConnected: "Nie gekoppel nie. Gebruik Meld aan indien beskikbaar, of voer {command} uit in 'n terminaal.", runInTerminal: "in 'n terminaal.", noProviders: "Geen OAuth-bekwame verskaffers opgespoor nie.", login: "Meld aan", disconnect: "Ontkoppel", managedExternally: "Ekstern bestuur", copied: "Gekopieer ✓", + copyCode: "Kopieer kode", + copyFailed: "Kon nie outomaties kopieer nie. Kies die kode en kopieer dit met die hand.", cli: "Kopieer", copyCliCommand: "Kopieer CLI-opdrag (vir ekstern / terugval)", connect: "Koppel", diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 4f316710406e..4a5bcb234611 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -446,16 +446,19 @@ export const de: Translations = { oauth: { title: "Anbieter-Logins (OAuth)", providerLogins: "Anbieter-Logins (OAuth)", - description: "{connected} von {total} OAuth-Anbietern verbunden. Login-Abläufe laufen derzeit über die CLI; klicke auf Befehl kopieren und füge ihn in ein Terminal ein, um einzurichten.", + description: + "{connected} von {total} OAuth-Anbietern verbunden. Nutze Anmelden für vom Dashboard unterstützte Abläufe; CLI-Befehle bleiben für externe oder Fallback-Einrichtung verfügbar.", connected: "Verbunden", expired: "Abgelaufen", - notConnected: "Nicht verbunden. Führe {command} in einem Terminal aus.", + notConnected: "Nicht verbunden. Nutze Anmelden, falls verfügbar, oder führe {command} in einem Terminal aus.", runInTerminal: "in einem Terminal.", noProviders: "Keine OAuth-fähigen Anbieter erkannt.", login: "Anmelden", disconnect: "Trennen", managedExternally: "Extern verwaltet", copied: "Kopiert ✓", + copyCode: "Code kopieren", + copyFailed: "Automatisches Kopieren fehlgeschlagen. Markiere den Code und kopiere ihn manuell.", cli: "Kopieren", copyCliCommand: "CLI-Befehl kopieren (für extern / Fallback)", connect: "Verbinden", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index e2ba0cc03692..a8be9310835a 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -501,16 +501,19 @@ export const en: Translations = { oauth: { title: "Provider Logins (OAuth)", providerLogins: "Provider Logins (OAuth)", - description: "{connected} of {total} OAuth providers connected. Login flows currently run via the CLI; click Copy command and paste into a terminal to set up.", + description: + "{connected} of {total} OAuth providers connected. Use Login for dashboard-supported flows; CLI commands remain available for external or fallback setup.", connected: "Connected", expired: "Expired", - notConnected: "Not connected. Run {command} in a terminal.", + notConnected: "Not connected. Use Login when available, or run {command} in a terminal.", runInTerminal: "in a terminal.", noProviders: "No OAuth-capable providers detected.", login: "Login", disconnect: "Disconnect", managedExternally: "Managed externally", copied: "Copied ✓", + copyCode: "Copy code", + copyFailed: "Could not copy automatically. Select the code and copy it manually.", cli: "Copy", copyCliCommand: "Copy CLI command (for external / fallback)", connect: "Connect", diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index 2cf46f3007c0..fd209b5c1d65 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -447,16 +447,19 @@ export const es: Translations = { oauth: { title: "Inicios de sesión de proveedores (OAuth)", providerLogins: "Inicios de sesión de proveedores (OAuth)", - description: "{connected} de {total} proveedores OAuth conectados. Los flujos de inicio de sesión actualmente se ejecutan a través de la CLI; haz clic en Copiar comando y pégalo en una terminal para configurar.", + description: + "{connected} de {total} proveedores OAuth conectados. Usa Iniciar sesión para los flujos compatibles con el panel; los comandos CLI siguen disponibles para configuración externa o de respaldo.", connected: "Conectado", expired: "Caducado", - notConnected: "No conectado. Ejecuta {command} en una terminal.", + notConnected: "No conectado. Usa Iniciar sesión si está disponible, o ejecuta {command} en una terminal.", runInTerminal: "en una terminal.", noProviders: "No se han detectado proveedores compatibles con OAuth.", login: "Iniciar sesión", disconnect: "Desconectar", managedExternally: "Gestionado externamente", copied: "Copiado ✓", + copyCode: "Copiar código", + copyFailed: "No se pudo copiar automáticamente. Selecciona el código y cópialo manualmente.", cli: "Copiar", copyCliCommand: "Copiar comando CLI (para externo / alternativa)", connect: "Conectar", diff --git a/web/src/i18n/fr.ts b/web/src/i18n/fr.ts index 7c321b0d196c..f822232cc693 100644 --- a/web/src/i18n/fr.ts +++ b/web/src/i18n/fr.ts @@ -447,16 +447,19 @@ export const fr: Translations = { oauth: { title: "Connexions fournisseurs (OAuth)", providerLogins: "Connexions fournisseurs (OAuth)", - description: "{connected} sur {total} fournisseurs OAuth connectés. Les flux de connexion s'exécutent actuellement via le CLI ; cliquez sur Copier la commande et collez-la dans un terminal pour configurer.", + description: + "{connected} sur {total} fournisseurs OAuth connectés. Utilisez Connexion pour les flux pris en charge par le tableau de bord ; les commandes CLI restent disponibles pour une configuration externe ou de secours.", connected: "Connecté", expired: "Expiré", - notConnected: "Non connecté. Exécutez {command} dans un terminal.", + notConnected: "Non connecté. Utilisez Connexion si disponible, ou exécutez {command} dans un terminal.", runInTerminal: "dans un terminal.", noProviders: "Aucun fournisseur compatible OAuth détecté.", login: "Connexion", disconnect: "Déconnecter", managedExternally: "Géré en externe", copied: "Copié ✓", + copyCode: "Copier le code", + copyFailed: "Copie automatique impossible. Sélectionnez le code et copiez-le manuellement.", cli: "Copier", copyCliCommand: "Copier la commande CLI (pour externe / repli)", connect: "Connecter", diff --git a/web/src/i18n/ga.ts b/web/src/i18n/ga.ts index 2bd97ed5a586..7f7f82ef1911 100644 --- a/web/src/i18n/ga.ts +++ b/web/src/i18n/ga.ts @@ -454,16 +454,19 @@ export const ga: Translations = { oauth: { title: "Logálacha isteach soláthraí (OAuth)", providerLogins: "Logálacha isteach soláthraí (OAuth)", - description: "{connected} as {total} soláthraí OAuth ceangailte. Reáchtáiltear sreabha logála isteach faoi láthair tríd an CLI; cliceáil Cóipeáil ordú agus greamaigh i dteirminéal chun é a shocrú.", + description: + "{connected} as {total} soláthraí OAuth ceangailte. Úsáid Logáil isteach le haghaidh sreabha a dtacaíonn an deais leo; tá orduithe CLI ar fáil i gcónaí do shocrú seachtrach nó cúltaca.", connected: "Ceangailte", expired: "As feidhm", - notConnected: "Gan cheangal. Rith {command} i dteirminéal.", + notConnected: "Gan cheangal. Úsáid Logáil isteach má tá sé ar fáil, nó rith {command} i dteirminéal.", runInTerminal: "i dteirminéal.", noProviders: "Níor aimsíodh soláthraithe a thacaíonn le OAuth.", login: "Logáil isteach", disconnect: "Dícheangail", managedExternally: "Bainistithe go seachtrach", copied: "Cóipeáilte ✓", + copyCode: "Cóipeáil an cód", + copyFailed: "Níorbh fhéidir cóipeáil go huathoibríoch. Roghnaigh an cód agus cóipeáil de láimh é.", cli: "Cóipeáil", copyCliCommand: "Cóipeáil ordú CLI (le haghaidh úsáide seachtraí / cúltaca)", connect: "Ceangail", diff --git a/web/src/i18n/hu.ts b/web/src/i18n/hu.ts index f09e3264ea9a..beab21dcc8da 100644 --- a/web/src/i18n/hu.ts +++ b/web/src/i18n/hu.ts @@ -446,16 +446,19 @@ export const hu: Translations = { oauth: { title: "Szolgáltatói bejelentkezések (OAuth)", providerLogins: "Szolgáltatói bejelentkezések (OAuth)", - description: "{connected} / {total} OAuth-szolgáltató csatlakoztatva. A bejelentkezési folyamat jelenleg a CLI-n keresztül fut; kattintson a Parancs másolása gombra, és illessze be egy terminálba a beállításhoz.", + description: + "{connected} / {total} OAuth-szolgáltató csatlakoztatva. Használja a Bejelentkezés gombot az irányítópult által támogatott folyamatokhoz; a CLI-parancsok továbbra is elérhetők külső vagy tartalék beállításhoz.", connected: "Csatlakoztatva", expired: "Lejárt", - notConnected: "Nincs csatlakoztatva. Futtassa a {command} parancsot egy terminálban.", + notConnected: "Nincs csatlakoztatva. Használja a Bejelentkezés gombot, ha elérhető, vagy futtassa a {command} parancsot egy terminálban.", runInTerminal: "egy terminálban.", noProviders: "Nem észlelhető OAuth-képes szolgáltató.", login: "Bejelentkezés", disconnect: "Lecsatlakozás", managedExternally: "Külsőleg kezelt", copied: "Másolva ✓", + copyCode: "Kód másolása", + copyFailed: "Nem sikerült automatikusan másolni. Jelölje ki a kódot, és másolja kézzel.", cli: "Másolás", copyCliCommand: "CLI-parancs másolása (külső / tartalék)", connect: "Csatlakozás", diff --git a/web/src/i18n/it.ts b/web/src/i18n/it.ts index 927efa256c1d..beb22f5f0dfd 100644 --- a/web/src/i18n/it.ts +++ b/web/src/i18n/it.ts @@ -446,16 +446,19 @@ export const it: Translations = { oauth: { title: "Accessi provider (OAuth)", providerLogins: "Accessi provider (OAuth)", - description: "{connected} di {total} provider OAuth connessi. I flussi di accesso vengono attualmente eseguiti tramite la CLI; clicca Copia comando e incolla in un terminale per configurare.", + description: + "{connected} di {total} provider OAuth connessi. Usa Accedi per i flussi supportati dalla dashboard; i comandi CLI restano disponibili per configurazioni esterne o di riserva.", connected: "Connesso", expired: "Scaduto", - notConnected: "Non connesso. Esegui {command} in un terminale.", + notConnected: "Non connesso. Usa Accedi se disponibile, oppure esegui {command} in un terminale.", runInTerminal: "in un terminale.", noProviders: "Nessun provider compatibile con OAuth rilevato.", login: "Accedi", disconnect: "Disconnetti", managedExternally: "Gestito esternamente", copied: "Copiato ✓", + copyCode: "Copia codice", + copyFailed: "Impossibile copiare automaticamente. Seleziona il codice e copialo manualmente.", cli: "Copia", copyCliCommand: "Copia comando CLI (per uso esterno / fallback)", connect: "Connetti", diff --git a/web/src/i18n/ja.ts b/web/src/i18n/ja.ts index 4de06a02ece3..b3bb658a3298 100644 --- a/web/src/i18n/ja.ts +++ b/web/src/i18n/ja.ts @@ -445,16 +445,19 @@ export const ja: Translations = { oauth: { title: "プロバイダーログイン (OAuth)", providerLogins: "プロバイダーログイン (OAuth)", - description: "{connected} / {total} OAuth プロバイダーが接続されています。ログインフローは現在 CLI 経由で実行されます。「コマンドをコピー」をクリックして、ターミナルに貼り付けてセットアップしてください。", + description: + "{connected} / {total} OAuth プロバイダーが接続されています。ダッシュボード対応のフローには「ログイン」を使用してください。外部またはフォールバック用のセットアップには引き続き CLI コマンドを利用できます。", connected: "接続済み", expired: "期限切れ", - notConnected: "未接続です。ターミナルで {command} を実行してください。", + notConnected: "未接続です。可能な場合は「ログイン」を使用するか、ターミナルで {command} を実行してください。", runInTerminal: "ターミナルで実行してください。", noProviders: "OAuth 対応プロバイダーは検出されませんでした。", login: "ログイン", disconnect: "切断", managedExternally: "外部で管理", copied: "コピーしました ✓", + copyCode: "コードをコピー", + copyFailed: "自動でコピーできませんでした。コードを選択して手動でコピーしてください。", cli: "コピー", copyCliCommand: "CLI コマンドをコピー (外部 / フォールバック用)", connect: "接続", diff --git a/web/src/i18n/ko.ts b/web/src/i18n/ko.ts index 6cd65f3133fe..adac224948c4 100644 --- a/web/src/i18n/ko.ts +++ b/web/src/i18n/ko.ts @@ -385,7 +385,7 @@ export const ko: Translations = { rawYaml: "원본 YAML 설정", searchResults: "검색 결과", fields: "개 필드", - noFieldsMatch: '\"{query}\"와(과) 일치하는 필드가 없습니다', + noFieldsMatch: '"{query}"와(과) 일치하는 필드가 없습니다', configSaved: "설정이 저장되었습니다", yamlConfigSaved: "YAML 설정이 저장되었습니다", failedToSave: "저장에 실패했습니다", @@ -445,16 +445,19 @@ export const ko: Translations = { oauth: { title: "제공자 로그인 (OAuth)", providerLogins: "제공자 로그인 (OAuth)", - description: "{connected}/{total} OAuth 제공자가 연결되었습니다. 로그인 흐름은 현재 CLI를 통해 실행됩니다. 명령 복사를 클릭하고 터미널에 붙여넣어 설정하세요.", + description: + "{connected}/{total} OAuth 제공자가 연결되었습니다. 대시보드에서 지원되는 흐름에는 로그인을 사용하세요. 외부 또는 대체 설정에는 CLI 명령을 계속 사용할 수 있습니다.", connected: "연결됨", expired: "만료됨", - notConnected: "연결되지 않음. 터미널에서 {command}을(를) 실행하세요.", + notConnected: "연결되지 않음. 가능하면 로그인을 사용하거나 터미널에서 {command}을(를) 실행하세요.", runInTerminal: "터미널에서.", noProviders: "OAuth를 지원하는 제공자가 감지되지 않았습니다.", login: "로그인", disconnect: "연결 해제", managedExternally: "외부에서 관리됨", copied: "복사됨 ✓", + copyCode: "코드 복사", + copyFailed: "자동으로 복사할 수 없습니다. 코드를 선택하여 직접 복사하세요.", cli: "복사", copyCliCommand: "CLI 명령 복사 (외부 / 대체용)", connect: "연결", diff --git a/web/src/i18n/pt.ts b/web/src/i18n/pt.ts index 90b5ea42355e..7e2513f4a9e1 100644 --- a/web/src/i18n/pt.ts +++ b/web/src/i18n/pt.ts @@ -447,16 +447,19 @@ export const pt: Translations = { oauth: { title: "Inícios de sessão de fornecedor (OAuth)", providerLogins: "Inícios de sessão de fornecedor (OAuth)", - description: "{connected} de {total} fornecedores OAuth ligados. Os fluxos de início de sessão são executados via CLI; clique em Copiar comando e cole num terminal para configurar.", + description: + "{connected} de {total} fornecedores OAuth ligados. Use Iniciar sessão para fluxos suportados pelo painel; os comandos CLI continuam disponíveis para configuração externa ou de recurso.", connected: "Ligado", expired: "Expirado", - notConnected: "Não ligado. Execute {command} num terminal.", + notConnected: "Não ligado. Use Iniciar sessão quando disponível, ou execute {command} num terminal.", runInTerminal: "num terminal.", noProviders: "Não foram detetados fornecedores compatíveis com OAuth.", login: "Iniciar sessão", disconnect: "Desligar", managedExternally: "Gerido externamente", copied: "Copiado ✓", + copyCode: "Copiar código", + copyFailed: "Não foi possível copiar automaticamente. Selecione o código e copie-o manualmente.", cli: "Copiar", copyCliCommand: "Copiar comando CLI (para externo / fallback)", connect: "Ligar", diff --git a/web/src/i18n/ru.ts b/web/src/i18n/ru.ts index c133f0398e98..c8db540f11df 100644 --- a/web/src/i18n/ru.ts +++ b/web/src/i18n/ru.ts @@ -446,16 +446,19 @@ export const ru: Translations = { oauth: { title: "Входы провайдеров (OAuth)", providerLogins: "Входы провайдеров (OAuth)", - description: "Подключено {connected} из {total} OAuth-провайдеров. Процесс входа в настоящее время выполняется через CLI; нажмите «Скопировать команду» и вставьте в терминал для настройки.", + description: + "Подключено {connected} из {total} OAuth-провайдеров. Используйте «Войти» для процессов, поддерживаемых панелью; команды CLI остаются доступными для внешней или резервной настройки.", connected: "Подключено", expired: "Срок истёк", - notConnected: "Не подключено. Выполните {command} в терминале.", + notConnected: "Не подключено. Используйте «Войти», если доступно, или выполните {command} в терминале.", runInTerminal: "в терминале.", noProviders: "OAuth-совместимые провайдеры не обнаружены.", login: "Войти", disconnect: "Отключить", managedExternally: "Управляется извне", copied: "Скопировано ✓", + copyCode: "Скопировать код", + copyFailed: "Не удалось скопировать автоматически. Выделите код и скопируйте его вручную.", cli: "Копировать", copyCliCommand: "Скопировать CLI-команду (для внешнего / резервного варианта)", connect: "Подключить", diff --git a/web/src/i18n/tr.ts b/web/src/i18n/tr.ts index e23dad98d8f9..dc9a62894b1b 100644 --- a/web/src/i18n/tr.ts +++ b/web/src/i18n/tr.ts @@ -446,16 +446,19 @@ export const tr: Translations = { oauth: { title: "Sağlayıcı Girişleri (OAuth)", providerLogins: "Sağlayıcı Girişleri (OAuth)", - description: "{connected}/{total} OAuth sağlayıcısı bağlandı. Giriş akışları şu anda CLI üzerinden çalışır; Komutu kopyala'ya tıklayın ve kurmak için bir terminale yapıştırın.", + description: + "{connected}/{total} OAuth sağlayıcısı bağlandı. Panel destekli akışlar için Giriş'i kullanın; CLI komutları harici veya yedek kurulum için kullanılabilir.", connected: "Bağlandı", expired: "Süresi doldu", - notConnected: "Bağlı değil. Bir terminalde {command} komutunu çalıştırın.", + notConnected: "Bağlı değil. Mümkünse Giriş'i kullanın veya bir terminalde {command} komutunu çalıştırın.", runInTerminal: "bir terminalde.", noProviders: "OAuth uyumlu sağlayıcı algılanmadı.", login: "Giriş", disconnect: "Bağlantıyı kes", managedExternally: "Harici olarak yönetiliyor", copied: "Kopyalandı ✓", + copyCode: "Kodu kopyala", + copyFailed: "Otomatik olarak kopyalanamadı. Kodu seçip elle kopyalayın.", cli: "Kopyala", copyCliCommand: "CLI komutunu kopyala (harici / yedek için)", connect: "Bağlan", diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index a93f921cadf0..6a888226adbf 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -530,6 +530,8 @@ export interface Translations { disconnect: string; managedExternally: string; copied: string; + copyCode: string; + copyFailed: string; cli: string; copyCliCommand: string; connect: string; diff --git a/web/src/i18n/uk.ts b/web/src/i18n/uk.ts index 8c329b731c37..7cf91b024803 100644 --- a/web/src/i18n/uk.ts +++ b/web/src/i18n/uk.ts @@ -387,7 +387,7 @@ export const uk: Translations = { rawYaml: "Сирий YAML-конфіг", searchResults: "Результати пошуку", fields: "поле(ів)", - noFieldsMatch: 'Немає полів, що відповідають \"{query}\"', + noFieldsMatch: 'Немає полів, що відповідають "{query}"', configSaved: "Конфігурацію збережено", yamlConfigSaved: "YAML-конфігурацію збережено", failedToSave: "Не вдалося зберегти", @@ -447,16 +447,19 @@ export const uk: Translations = { oauth: { title: "Входи постачальників (OAuth)", providerLogins: "Входи постачальників (OAuth)", - description: "Підключено {connected} з {total} постачальників OAuth. Процеси входу наразі виконуються через CLI; натисніть «Скопіювати команду» та вставте у термінал, щоб налаштувати.", + description: + "Підключено {connected} з {total} постачальників OAuth. Використовуйте «Увійти» для процесів, підтримуваних панеллю; команди CLI залишаються доступними для зовнішнього або резервного налаштування.", connected: "Підключено", expired: "Прострочено", - notConnected: "Не підключено. Виконайте {command} у терміналі.", + notConnected: "Не підключено. Використовуйте «Увійти», якщо доступно, або виконайте {command} у терміналі.", runInTerminal: "у терміналі.", noProviders: "Не виявлено постачальників із підтримкою OAuth.", login: "Увійти", disconnect: "Відключити", managedExternally: "Керується ззовні", copied: "Скопійовано ✓", + copyCode: "Скопіювати код", + copyFailed: "Не вдалося скопіювати автоматично. Виділіть код і скопіюйте його вручну.", cli: "Копіювати", copyCliCommand: "Скопіювати CLI-команду (для зовнішнього / резервного варіанту)", connect: "Підключити", diff --git a/web/src/i18n/zh-hant.ts b/web/src/i18n/zh-hant.ts index c4ec4af3e77a..eeb72988ca12 100644 --- a/web/src/i18n/zh-hant.ts +++ b/web/src/i18n/zh-hant.ts @@ -445,16 +445,19 @@ export const zhHant: Translations = { oauth: { title: "提供者登入(OAuth)", providerLogins: "提供者登入(OAuth)", - description: "已連線 {connected}/{total} 個 OAuth 提供者。登入流程目前透過 CLI 執行;請點擊「複製指令」並貼到終端機完成設定。", + description: + "已連線 {connected}/{total} 個 OAuth 提供者。儀表板支援的流程請使用「登入」;CLI 指令仍可用於外部或備用設定。", connected: "已連線", expired: "已過期", - notConnected: "未連線。請在終端機執行 {command}。", + notConnected: "未連線。可用時請使用「登入」,或在終端機執行 {command}。", runInTerminal: "於終端機。", noProviders: "未偵測到支援 OAuth 的提供者。", login: "登入", disconnect: "中斷連線", managedExternally: "由外部管理", copied: "已複製 ✓", + copyCode: "複製代碼", + copyFailed: "無法自動複製。請選取代碼並手動複製。", cli: "複製", copyCliCommand: "複製 CLI 指令(外部 / 備援用)", connect: "連線", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b34f3341a42b..4cf821c85aa1 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -440,16 +440,19 @@ export const zh: Translations = { oauth: { title: "提供商登录(OAuth)", providerLogins: "提供商登录(OAuth)", - description: "已连接 {connected}/{total} 个 OAuth 提供商。登录流程目前通过 CLI 运行;点击「复制命令」并粘贴到终端中进行设置。", + description: + "已连接 {connected}/{total} 个 OAuth 提供商。仪表板支持的流程请使用「登录」;CLI 命令仍可用于外部或备用设置。", connected: "已连接", expired: "已过期", - notConnected: "未连接。在终端中运行 {command}。", + notConnected: "未连接。可用时请使用「登录」,或在终端中运行 {command}。", runInTerminal: "在终端中。", noProviders: "未检测到支持 OAuth 的提供商。", login: "登录", disconnect: "断开连接", managedExternally: "外部管理", copied: "已复制 ✓", + copyCode: "复制代码", + copyFailed: "无法自动复制。请选中代码并手动复制。", cli: "复制", copyCliCommand: "复制 CLI 命令(用于外部/备用方式)", connect: "连接", diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index 4da9234ac5d7..4d63d51a01a3 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -2,21 +2,28 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { api } from "./api"; +const SESSION_HEADER = "X-Hermes-Session-Token"; + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); +function jsonFetchMock(body: unknown = { ok: true }) { + return vi.fn( + async () => + new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); +} + describe("api.getModelOptions", () => { it("requests a live model refresh when asked", async () => { vi.stubGlobal("window", {}); - const fetchMock = vi.fn(async () => - new Response(JSON.stringify({ providers: [] }), { - headers: { "Content-Type": "application/json" }, - status: 200, - }), - ); + const fetchMock = jsonFetchMock({ providers: [] }); vi.stubGlobal("fetch", fetchMock); await api.getModelOptions({ refresh: true }); @@ -30,12 +37,7 @@ describe("api.getModelOptions", () => { it("keeps explicit profile scoping when refreshing", async () => { vi.stubGlobal("window", {}); - const fetchMock = vi.fn(async () => - new Response(JSON.stringify({ providers: [] }), { - headers: { "Content-Type": "application/json" }, - status: 200, - }), - ); + const fetchMock = jsonFetchMock({ providers: [] }); vi.stubGlobal("fetch", fetchMock); await api.getModelOptions({ profile: "default", refresh: true }); @@ -46,3 +48,59 @@ describe("api.getModelOptions", () => { ); }); }); + +describe("api OAuth helpers", () => { + it("starts OAuth login in gated mode without requiring an injected session token", async () => { + vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true }); + const fetchMock = jsonFetchMock({ + flow: "device_code", + session_id: "oauth-session", + }); + vi.stubGlobal("fetch", fetchMock); + + await api.startOAuthLogin("openai-codex"); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/providers/oauth/openai-codex/start", + expect.objectContaining({ + body: "{}", + credentials: "include", + method: "POST", + }), + ); + const headers = fetchMock.mock.calls[0][1]?.headers as Headers; + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.has(SESSION_HEADER)).toBe(false); + }); + + it("still sends the injected session token for OAuth login in loopback mode", async () => { + vi.stubGlobal("window", { __HERMES_SESSION_TOKEN__: "loopback-token" }); + const fetchMock = jsonFetchMock({ + flow: "device_code", + session_id: "oauth-session", + }); + vi.stubGlobal("fetch", fetchMock); + + await api.startOAuthLogin("openai-codex"); + + const headers = fetchMock.mock.calls[0][1]?.headers as Headers; + expect(headers.get(SESSION_HEADER)).toBe("loopback-token"); + }); + + it("runs provider auth mutations in gated mode via cookie auth", async () => { + vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true }); + const fetchMock = jsonFetchMock({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + await api.disconnectOAuthProvider("anthropic"); + await api.submitOAuthCode("anthropic", "oauth-session", "code-123"); + await api.cancelOAuthSession("oauth-session"); + await api.revealEnvVar("OPENAI_API_KEY"); + + for (const call of fetchMock.mock.calls) { + const init = call[1] as RequestInit; + expect(init.credentials).toBe("include"); + expect((init.headers as Headers).has(SESSION_HEADER)).toBe(false); + } + }); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e63208129820..d65a50153909 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -34,7 +34,6 @@ declare global { __HERMES_AUTH_REQUIRED__?: boolean; } } -let _sessionToken: string | null = null; const SESSION_HEADER = "X-Hermes-Session-Token"; function setSessionHeader(headers: Headers, token: string): void { @@ -197,16 +196,6 @@ function pluginPath(name: string): string { return name.split("/").map(encodeURIComponent).join("/"); } -async function getSessionToken(): Promise { - if (_sessionToken) return _sessionToken; - const injected = window.__HERMES_SESSION_TOKEN__; - if (injected) { - _sessionToken = injected; - return _sessionToken; - } - throw new Error("Session token not available — page must be served by the Hermes dashboard server"); -} - /** * Fetch a single-use ticket for a WebSocket upgrade in gated mode. * @@ -414,6 +403,15 @@ export const api = { fetchJSON(appendProfileParam("/api/sessions/stats", profile)), exportSessionUrl: (id: string, profile = getManagementProfile()) => appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/export`, profile), + importSessions: ( + sessions: Array>, + profile = getManagementProfile(), + ) => + fetchJSON("/api/sessions/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessions, profile: profile || undefined }), + }), pruneSessions: ( older_than_days: number, source?: string, @@ -553,17 +551,12 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }), }), - revealEnvVar: async (key: string) => { - const token = await getSessionToken(); - return fetchJSON<{ key: string; value: string }>("/api/env/reveal", { + revealEnvVar: (key: string) => + fetchJSON<{ key: string; value: string }>("/api/env/reveal", { method: "POST", - headers: { - "Content-Type": "application/json", - [SESSION_HEADER]: token, - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }), - }); - }, + }), // Cron jobs getCronJobs: (profile = "all") => @@ -788,58 +781,42 @@ export const api = { // OAuth provider management getOAuthProviders: () => fetchJSON("/api/providers/oauth"), - disconnectOAuthProvider: async (providerId: string) => { - const token = await getSessionToken(); - return fetchJSON<{ ok: boolean; provider: string }>( + disconnectOAuthProvider: (providerId: string) => + fetchJSON<{ ok: boolean; provider: string }>( `/api/providers/oauth/${encodeURIComponent(providerId)}`, { method: "DELETE", - headers: { [SESSION_HEADER]: token }, }, - ); - }, - startOAuthLogin: async (providerId: string) => { - const token = await getSessionToken(); - return fetchJSON( + ), + startOAuthLogin: (providerId: string) => + fetchJSON( `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, { method: "POST", - headers: { - "Content-Type": "application/json", - [SESSION_HEADER]: token, - }, + headers: { "Content-Type": "application/json" }, body: "{}", }, - ); - }, - submitOAuthCode: async (providerId: string, sessionId: string, code: string) => { - const token = await getSessionToken(); - return fetchJSON( + ), + submitOAuthCode: (providerId: string, sessionId: string, code: string) => + fetchJSON( `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, { method: "POST", - headers: { - "Content-Type": "application/json", - [SESSION_HEADER]: token, - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId, code }), }, - ); - }, + ), pollOAuthSession: (providerId: string, sessionId: string) => fetchJSON( `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`, ), - cancelOAuthSession: async (sessionId: string) => { - const token = await getSessionToken(); - return fetchJSON<{ ok: boolean }>( + cancelOAuthSession: (sessionId: string) => + fetchJSON<{ ok: boolean }>( `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE", - headers: { [SESSION_HEADER]: token }, }, - ); - }, + ), // Messaging platforms (gateway channels) getMessagingPlatforms: () => @@ -1018,6 +995,11 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }), + authMcpServer: (name: string) => + fetchJSON( + `/api/mcp/servers/${encodeURIComponent(name)}/auth`, + { method: "POST" }, + ), removeMcpServer: (name: string) => fetchJSON<{ ok: boolean }>(`/api/mcp/servers/${encodeURIComponent(name)}`, { method: "DELETE", @@ -1330,6 +1312,16 @@ export interface SessionStoreStats { by_source: Record; } +export interface SessionImportResponse { + ok: boolean; + imported: number; + skipped: number; + detached: number; + imported_ids: string[]; + skipped_ids: string[]; + errors: Array>; +} + export interface SkillHubResult { name: string; description: string; @@ -1420,7 +1412,7 @@ export interface McpServer { command: string | null; args: string[]; env: Record; - auth: string | null; + auth: "header" | "oauth" | null; enabled: boolean; tools: string[] | null; } @@ -1455,13 +1447,16 @@ export interface McpCatalogDiagnostic { } +export type McpHttpAuth = "none" | "header" | "oauth"; + export interface McpServerCreate { name: string; url?: string; command?: string; args?: string[]; env?: Record; - auth?: string; + auth?: McpHttpAuth; + bearer_token?: string; } export interface McpTestResult { @@ -2314,6 +2309,8 @@ export interface AuxiliaryModelsResponse { export interface MoaModelSlot { provider: string; model: string; + /** Optional per-slot reasoning effort — round-tripped, not edited here. */ + reasoning_effort?: string; } export interface MoaConfigResponse { @@ -2325,6 +2322,10 @@ export interface MoaConfigResponse { reference_temperature: number; aggregator_temperature: number; max_tokens: number; + /** Optional advisor output cap — round-tripped, not edited here. */ + reference_max_tokens?: number | null; + /** Fan-out cadence (per_iteration | user_turn) — round-tripped. */ + fanout?: string; enabled: boolean; }>; reference_models: MoaModelSlot[]; diff --git a/web/src/lib/chat-sidebar-session-params.test.ts b/web/src/lib/chat-sidebar-session-params.test.ts new file mode 100644 index 000000000000..212360d37e76 --- /dev/null +++ b/web/src/lib/chat-sidebar-session-params.test.ts @@ -0,0 +1,35 @@ +/** + * Tests for the sidecar ``session.create`` params built by ChatSidebar. + * + * The sidecar must opt its session into close_on_disconnect so the gateway + * reaps the slash_worker on WS disconnect (the #21370/#21467 leak), and it + * must pass the dashboard's selected profile so model/credential info + * matches the PTY child under profile-scoped chat. + * + */ + +import { describe, expect, it } from "vitest"; + +import { sidecarSessionCreateParams } from "@/components/ChatSidebar"; + +describe("sidecarSessionCreateParams", () => { + it("opts into close_on_disconnect", () => { + const params = sidecarSessionCreateParams(); + expect(params.close_on_disconnect).toBe(true); + }); + + it("sets source to 'tool'", () => { + const params = sidecarSessionCreateParams(); + expect(params.source).toBe("tool"); + }); + + it("forwards the profile when present", () => { + const params = sidecarSessionCreateParams("work"); + expect(params.profile).toBe("work"); + }); + + it("omits profile when undefined", () => { + const params = sidecarSessionCreateParams(); + expect(params).not.toHaveProperty("profile"); + }); +}); diff --git a/web/src/lib/chatImagePaste.test.ts b/web/src/lib/chatImagePaste.test.ts new file mode 100644 index 000000000000..047dec175289 --- /dev/null +++ b/web/src/lib/chatImagePaste.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { + firstImageFromClipboard, + imageFilesFromTransfer, + transferMayContainImage, +} from "./chatImagePaste"; + +// Minimal DataTransfer stand-ins. jsdom's DataTransfer doesn't let us seed +// items/files, so we hand-roll the shape the helpers read. +function makeItem(kind: string, type: string, file: File | null) { + return { kind, type, getAsFile: () => file } as unknown as DataTransferItem; +} + +function makeData(opts: { + items?: DataTransferItem[]; + files?: File[]; +}): DataTransfer { + const items = opts.items ?? []; + const files = opts.files ?? []; + const itemList: Record = { length: items.length }; + items.forEach((it, i) => { + itemList[i] = it; + }); + const fileList: Record = { length: files.length }; + files.forEach((f, i) => { + fileList[i] = f; + }); + return { + items: itemList, + files: fileList, + } as unknown as DataTransfer; +} + +const png = new File([new Uint8Array([1, 2, 3])], "x.png", { + type: "image/png", +}); +const gif = new File([new Uint8Array([4, 5])], "y.gif", { + type: "image/gif", +}); + +describe("firstImageFromClipboard", () => { + it("returns null for null clipboard data", () => { + expect(firstImageFromClipboard(null)).toBeNull(); + }); + + it("finds an image via items[].getAsFile()", () => { + const data = makeData({ items: [makeItem("file", "image/png", png)] }); + expect(firstImageFromClipboard(data)).toBe(png); + }); + + it("ignores non-file and non-image items", () => { + const data = makeData({ + items: [ + makeItem("string", "text/plain", null), + makeItem("file", "application/pdf", new File([], "a.pdf")), + ], + }); + expect(firstImageFromClipboard(data)).toBeNull(); + }); + + it("falls back to files[] when items are absent (Safari/Firefox)", () => { + const data = makeData({ files: [png] }); + expect(firstImageFromClipboard(data)).toBe(png); + }); + + it("returns null when nothing image-like is present", () => { + const data = makeData({ + files: [new File([], "notes.txt", { type: "text/plain" })], + }); + expect(firstImageFromClipboard(data)).toBeNull(); + }); +}); + +describe("imageFilesFromTransfer", () => { + it("dedupes the same file when present in both items and files", () => { + const data = makeData({ + items: [makeItem("file", "image/png", png)], + files: [png, gif], + }); + expect(imageFilesFromTransfer(data)).toEqual([png, gif]); + }); +}); + +describe("transferMayContainImage", () => { + it("is true for image items even when type is empty (some browsers)", () => { + const data = makeData({ + items: [makeItem("file", "", png)], + }); + expect(transferMayContainImage(data)).toBe(true); + }); + + it("is false for text-only transfers", () => { + const data = makeData({ + items: [makeItem("string", "text/plain", null)], + }); + expect(transferMayContainImage(data)).toBe(false); + }); +}); diff --git a/web/src/lib/chatImagePaste.ts b/web/src/lib/chatImagePaste.ts new file mode 100644 index 000000000000..3767cf803746 --- /dev/null +++ b/web/src/lib/chatImagePaste.ts @@ -0,0 +1,164 @@ +import { authedFetch } from "@/lib/api"; + +// Clipboard image MIME → file extension. Mirrors the set the TUI's /image +// attach path and the gateway's image sniffer accept. +const IMAGE_MIME_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", +}; + +// Anthropic caps a single vision image at ~25 MB; reject earlier client-side +// with a clear message rather than round-tripping a doomed upload. +const MAX_IMAGE_BYTES = 25 * 1024 * 1024; + +export interface ChatImageUploadResult { + /** Absolute path under HERMES_HOME/images the gateway wrote. */ + path: string; + /** Byte size of the uploaded image. */ + bytes: number; + /** Basename written on the server. */ + name: string; + mime_type: string; +} + +function imageFileKey(file: File): string { + return `${file.name}\0${file.type}\0${file.size}\0${file.lastModified}`; +} + +function addImageFile(files: File[], seen: Set, file: File | null) { + if (!file || !file.type.startsWith("image/")) return; + const key = imageFileKey(file); + if (seen.has(key)) return; + seen.add(key); + files.push(file); +} + +/** Pull every image file out of a DataTransfer (clipboard or drop). */ +export function imageFilesFromTransfer( + data: DataTransfer | null, +): File[] { + if (!data) return []; + const files: File[] = []; + const seen = new Set(); + + if (data.items?.length) { + for (let i = 0; i < data.items.length; i++) { + const item = data.items[i]; + if (item.kind === "file" && item.type.startsWith("image/")) { + addImageFile(files, seen, item.getAsFile()); + } + } + } + + if (data.files?.length) { + for (let i = 0; i < data.files.length; i++) { + addImageFile(files, seen, data.files[i]); + } + } + + return files; +} + +/** Pull the first image blob out of a DataTransfer, or null if none present. */ +export function firstImageFromClipboard( + data: DataTransfer | null, +): File | null { + return imageFilesFromTransfer(data)[0] ?? null; +} + +/** True when a drag payload may contain an image (for dragover preventDefault). */ +export function transferMayContainImage(data: DataTransfer | null): boolean { + if (!data) return false; + if (data.items?.length) { + for (let i = 0; i < data.items.length; i++) { + const item = data.items[i]; + if ( + item.kind === "file" && + (!item.type || item.type.startsWith("image/")) + ) { + return true; + } + } + return false; + } + if (data.files?.length) { + for (let i = 0; i < data.files.length; i++) { + if (data.files[i].type.startsWith("image/")) return true; + } + } + return false; +} + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => + reject(reader.error ?? new Error("image read failed")); + reader.onload = () => { + const result = reader.result; + if (typeof result === "string") { + resolve(result); + } else { + reject(new Error("image read failed")); + } + }; + reader.readAsDataURL(file); + }); +} + +/** + * Upload a browser clipboard/drop image to ``HERMES_HOME/images`` via the + * dedicated chat upload endpoint and return the absolute gateway path. + * + * The dashboard Chat tab is an xterm mirror of a TUI running INSIDE the + * gateway. The container has no access to the browser's clipboard, so the + * server-side ``clipboard.paste`` path can never see a pasted image. + * Upload the bytes the browser already holds, then hand the path to the + * TUI's ``/image`` command. + */ +export async function uploadChatImage( + blob: Blob, + profile = "", +): Promise { + if (blob.size === 0) throw new Error("clipboard image is empty"); + if (blob.size > MAX_IMAGE_BYTES) { + const mb = Math.round(MAX_IMAGE_BYTES / (1024 * 1024)); + throw new Error(`image too large (max ${mb} MB)`); + } + + const mime = blob.type || "image/png"; + const ext = IMAGE_MIME_EXT[mime] || "png"; + const filename = + blob instanceof File && blob.name + ? blob.name + : `clipboard.${ext}`; + const file = + blob instanceof File + ? blob + : new File([blob], filename, { type: mime }); + + const dataUrl = await fileToDataUrl(file); + const qs = profile ? `?profile=${encodeURIComponent(profile)}` : ""; + const res = await authedFetch(`/api/chat/image-upload${qs}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + data_url: dataUrl, + filename, + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + throw new Error(text || `HTTP ${res.status}`); + } + + const uploaded = (await res.json()) as ChatImageUploadResult; + if (!uploaded?.path) { + throw new Error("image upload did not return a path"); + } + return uploaded; +} diff --git a/web/src/lib/clipboard.test.ts b/web/src/lib/clipboard.test.ts new file mode 100644 index 000000000000..8f6fd1fba211 --- /dev/null +++ b/web/src/lib/clipboard.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { copyTextToClipboard } from "./clipboard"; + +const originalNavigator = globalThis.navigator; +const originalDocument = globalThis.document; +const originalWindow = globalThis.window; + +function setGlobal( + key: K, + value: (typeof globalThis)[K] | undefined, +) { + Object.defineProperty(globalThis, key, { + configurable: true, + value, + }); +} + +afterEach(() => { + setGlobal("navigator", originalNavigator); + setGlobal("document", originalDocument); + setGlobal("window", originalWindow); + vi.restoreAllMocks(); +}); + +describe("copyTextToClipboard", () => { + it("uses navigator.clipboard when available", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + setGlobal( + "navigator", + { clipboard: { writeText } } as unknown as Navigator, + ); + setGlobal("document", undefined); + + await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith("CODEX-1234"); + }); + + it("falls back to selection copy when Clipboard API fails", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("not allowed")); + const appendChild = vi.fn(); + const removeChild = vi.fn(); + const execCommand = vi.fn().mockReturnValue(true); + const textarea = { + focus: vi.fn(), + select: vi.fn(), + setAttribute: vi.fn(), + setSelectionRange: vi.fn(), + style: {}, + value: "", + } as unknown as HTMLTextAreaElement; + + setGlobal( + "navigator", + { clipboard: { writeText } } as unknown as Navigator, + ); + setGlobal("document", { + body: { appendChild, removeChild }, + createElement: vi.fn(() => textarea), + execCommand, + getSelection: vi.fn(() => null), + } as unknown as Document); + + await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true); + + expect(writeText).toHaveBeenCalledWith("CODEX-1234"); + expect(textarea.value).toBe("CODEX-1234"); + expect(appendChild).toHaveBeenCalledWith(textarea); + expect(textarea.select).toHaveBeenCalled(); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(removeChild).toHaveBeenCalledWith(textarea); + }); + + it("uses selection copy directly in insecure browser contexts", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const appendChild = vi.fn(); + const removeChild = vi.fn(); + const execCommand = vi.fn().mockReturnValue(true); + const textarea = { + focus: vi.fn(), + select: vi.fn(), + setAttribute: vi.fn(), + setSelectionRange: vi.fn(), + style: {}, + value: "", + } as unknown as HTMLTextAreaElement; + + setGlobal( + "navigator", + { clipboard: { writeText } } as unknown as Navigator, + ); + setGlobal( + "window", + { isSecureContext: false } as unknown as Window & typeof globalThis, + ); + setGlobal("document", { + body: { appendChild, removeChild }, + createElement: vi.fn(() => textarea), + execCommand, + getSelection: vi.fn(() => null), + } as unknown as Document); + + await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true); + + expect(writeText).not.toHaveBeenCalled(); + expect(execCommand).toHaveBeenCalledWith("copy"); + }); + + it("returns false when no copy mechanism is available", async () => { + setGlobal("navigator", {} as Navigator); + setGlobal("document", undefined); + + await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(false); + }); +}); diff --git a/web/src/lib/clipboard.ts b/web/src/lib/clipboard.ts new file mode 100644 index 000000000000..749168e8e5da --- /dev/null +++ b/web/src/lib/clipboard.ts @@ -0,0 +1,56 @@ +export async function copyTextToClipboard(text: string): Promise { + const clipboard = + typeof navigator === "undefined" ? undefined : navigator.clipboard; + const secureContext = + typeof window === "undefined" ? true : window.isSecureContext; + if (secureContext && clipboard?.writeText) { + try { + await clipboard.writeText(text); + return true; + } catch { + // Fall through to the selection-based copy path below. + } + } + + if (typeof document === "undefined") { + return false; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + textarea.style.left = "-1000px"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + + const selection = document.getSelection(); + const ranges: Range[] = []; + if (selection) { + for (let i = 0; i < selection.rangeCount; i += 1) { + ranges.push(selection.getRangeAt(i)); + } + } + + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + let copied = false; + try { + copied = document.execCommand("copy"); + } catch { + copied = false; + } finally { + document.body.removeChild(textarea); + if (selection) { + selection.removeAllRanges(); + for (const range of ranges) { + selection.addRange(range); + } + } + } + + return copied; +} diff --git a/web/src/lib/mcp-server-create.test.ts b/web/src/lib/mcp-server-create.test.ts new file mode 100644 index 000000000000..f0fc41866cee --- /dev/null +++ b/web/src/lib/mcp-server-create.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { buildMcpServerCreate, emptyMcpServerDraft } from "./mcp-server-create"; + +describe("buildMcpServerCreate", () => { + it("builds an HTTP Bearer request without stdio fields", () => { + const server = buildMcpServerCreate({ + ...emptyMcpServerDraft(), + name: " Linear ", + url: " https://mcp.linear.app/mcp ", + httpAuth: "header", + bearerToken: "Bearer secret-token", + command: "ignored", + args: "--ignored", + env: "IGNORED=value", + }); + + expect(server).toEqual({ + name: "Linear", + url: "https://mcp.linear.app/mcp", + auth: "header", + bearer_token: "Bearer secret-token", + }); + }); + + it("builds OAuth and unauthenticated HTTP requests without a token", () => { + expect( + buildMcpServerCreate({ + ...emptyMcpServerDraft(), + name: "oauth", + url: "https://example.com/mcp", + httpAuth: "oauth", + }), + ).toEqual({ + name: "oauth", + url: "https://example.com/mcp", + auth: "oauth", + }); + + expect( + buildMcpServerCreate({ + ...emptyMcpServerDraft(), + name: "public", + url: "https://example.com/mcp", + }), + ).toEqual({ + name: "public", + url: "https://example.com/mcp", + }); + }); + + it("parses stdio arguments and environment assignments", () => { + const server = buildMcpServerCreate({ + ...emptyMcpServerDraft(), + name: "local", + transport: "stdio", + command: " uvx ", + args: "mcp-server, --debug", + env: "API_KEY=secret\nURL=https://example.com?a=b\nINVALID", + }); + + expect(server).toEqual({ + name: "local", + command: "uvx", + args: ["mcp-server", "--debug"], + env: { + API_KEY: "secret", + URL: "https://example.com?a=b", + }, + }); + }); + + it("rejects missing transport fields and Bearer tokens", () => { + expect(() => buildMcpServerCreate(emptyMcpServerDraft())).toThrow( + "Name required", + ); + expect(() => + buildMcpServerCreate({ + ...emptyMcpServerDraft(), + name: "remote", + }), + ).toThrow("URL required"); + expect(() => + buildMcpServerCreate({ + ...emptyMcpServerDraft(), + name: "remote", + url: "https://example.com/mcp", + httpAuth: "header", + }), + ).toThrow("Bearer token required"); + expect(() => + buildMcpServerCreate({ + ...emptyMcpServerDraft(), + name: "local", + transport: "stdio", + }), + ).toThrow("Command required"); + }); +}); diff --git a/web/src/lib/mcp-server-create.ts b/web/src/lib/mcp-server-create.ts new file mode 100644 index 000000000000..c39c83825306 --- /dev/null +++ b/web/src/lib/mcp-server-create.ts @@ -0,0 +1,78 @@ +import type { McpHttpAuth, McpServerCreate } from "@/lib/api"; + +export type McpTransport = "http" | "stdio"; + +export interface McpServerDraft { + name: string; + transport: McpTransport; + url: string; + httpAuth: McpHttpAuth; + bearerToken: string; + command: string; + args: string; + env: string; +} + +export function emptyMcpServerDraft(): McpServerDraft { + return { + name: "", + transport: "http", + url: "", + httpAuth: "none", + bearerToken: "", + command: "", + args: "", + env: "", + }; +} + +function parseArgs(raw: string): string[] { + return raw + .split(/[\s,]+/) + .map((value) => value.trim()) + .filter(Boolean); +} + +function parseEnv(raw: string): Record { + const env: Record = {}; + for (const rawLine of raw.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + const separator = line.indexOf("="); + if (separator === -1) continue; + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + if (key) env[key] = value; + } + return env; +} + +export function buildMcpServerCreate(draft: McpServerDraft): McpServerCreate { + const name = draft.name.trim(); + if (!name) throw new Error("Name required"); + + if (draft.transport === "http") { + const url = draft.url.trim(); + if (!url) throw new Error("URL required"); + if (draft.httpAuth === "header" && !draft.bearerToken.trim()) { + throw new Error("Bearer token required"); + } + + const server: McpServerCreate = { name, url }; + if (draft.httpAuth !== "none") server.auth = draft.httpAuth; + if (draft.httpAuth === "header") { + server.bearer_token = draft.bearerToken; + } + return server; + } + + const command = draft.command.trim(); + if (!command) throw new Error("Command required"); + + const server: McpServerCreate = { name, command }; + const args = parseArgs(draft.args); + if (args.length) server.args = args; + const env = parseEnv(draft.env); + if (Object.keys(env).length) server.env = env; + return server; +} diff --git a/web/src/lib/pty-mobile-input.test.ts b/web/src/lib/pty-mobile-input.test.ts new file mode 100644 index 000000000000..5245a2e3186e --- /dev/null +++ b/web/src/lib/pty-mobile-input.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { + normalizePtyMobileInput, + shouldTreatInputAsMobileReplacement, + updatePtyInputLine, +} from "./pty-mobile-input"; + +describe("shouldTreatInputAsMobileReplacement", () => { + it("recognizes explicit browser replacement input", () => { + expect(shouldTreatInputAsMobileReplacement("insertReplacementText", "Kain", false)).toBe(true); + expect(shouldTreatInputAsMobileReplacement("insertFromComposition", "Kain", false)).toBe(true); + expect(shouldTreatInputAsMobileReplacement("insertCompositionText", "Kain", false)).toBe(true); + }); + + it("treats multi-character mobile insertText as replacement-like", () => { + expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", true)).toBe(true); + expect(shouldTreatInputAsMobileReplacement("insertText", "K", true)).toBe(false); + expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", false)).toBe(false); + }); +}); + +describe("normalizePtyMobileInput", () => { + it("turns a Gboard full-line suggestion into a line replacement", () => { + const result = normalizePtyMobileInput( + "hello my name is Kain Kain", + "hello my name is kain", + true, + ); + + expect(result.normalized).toBe(true); + expect(result.nextLine).toBe("hello my name is Kain"); + expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain"); + }); + + it("turns a Gboard last-word suggestion into a last-word replacement", () => { + const result = normalizePtyMobileInput("Kain", "hello my name is kain", true); + + expect(result.normalized).toBe(true); + expect(result.nextLine).toBe("hello my name is Kain"); + expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain"); + }); + + it("does not normalize ordinary appends when replacement is not active", () => { + const result = normalizePtyMobileInput( + "hello my name is Kain Kain", + "hello my name is kain", + false, + ); + + expect(result.normalized).toBe(false); + expect(result.nextLine).toBe("hello my name is kainhello my name is Kain Kain"); + }); + + it("does not normalize control input", () => { + const result = normalizePtyMobileInput("\r", "hello", true); + + expect(result.normalized).toBe(false); + expect(result.nextLine).toBe(""); + expect(result.data).toBe("\r"); + }); + + it("does not collapse legitimate single-letter reduplication", () => { + // "a a" is a plausible thing to type; the >=2-char guard keeps the + // duplicate-final-word collapse from eating it inside the window. + const result = normalizePtyMobileInput("a a", "a", true); + + expect(result.normalized).toBe(false); + expect(result.data).toBe("a a"); + }); +}); + +describe("updatePtyInputLine", () => { + it("tracks printable text, delete, and submit", () => { + expect(updatePtyInputLine("", "abc")).toBe("abc"); + expect(updatePtyInputLine("abc", "\x7f")).toBe("ab"); + expect(updatePtyInputLine("abc", "\r")).toBe(""); + }); + + it("resets tracking on escape sequences instead of appending their payload", () => { + // Left-arrow arrives as one CSI chunk; the tracker cannot model cursor + // moves, so it must disarm rather than record "hello[D". + expect(updatePtyInputLine("hello", "\x1b[D")).toBe(""); + expect(updatePtyInputLine("hello", "\x1b[H")).toBe(""); + expect(updatePtyInputLine("hello", "\x1bOP")).toBe(""); + }); +}); + +describe("normalizePtyMobileInput after cursor movement", () => { + it("does not emit a replacement against a tracker reset by arrow keys", () => { + // Simulate: type "hello my name is kain", press left-arrow, then a + // Gboard suggestion arrives. The tracker reset means no replacement + // heuristic can fire against a stale line snapshot. + const afterArrow = updatePtyInputLine("hello my name is kain", "\x1b[D"); + const result = normalizePtyMobileInput("Kain", afterArrow, true); + + expect(result.normalized).toBe(false); + expect(result.data).toBe("Kain"); + }); +}); diff --git a/web/src/lib/pty-mobile-input.ts b/web/src/lib/pty-mobile-input.ts new file mode 100644 index 000000000000..6c3b29d6aae6 --- /dev/null +++ b/web/src/lib/pty-mobile-input.ts @@ -0,0 +1,136 @@ +const DELETE = "\x7f"; + +// How long (ms) after a mobile IME / replacement event we treat subsequent +// terminal input as a candidate line-replacement rather than a plain append. +// Exported so the ChatPage integration and tests share one tunable value. +export const MOBILE_REPLACEMENT_WINDOW_MS = 350; + +function chars(text: string): string[] { + return Array.from(text); +} + +function removeLastChar(text: string): string { + const c = chars(text); + c.pop(); + return c.join(""); +} + + +function isPlainText(data: string): boolean { + // eslint-disable-next-line no-control-regex -- terminal data may contain control chars + return !/[\x00-\x1f\x7f]/.test(data); +} + +function lastWordMatch(line: string): RegExpMatchArray | null { + return line.match(/^(.*?)(\S+)(\s*)$/u); +} + +function collapseDuplicatedFinalWord(text: string, previousLine: string): string { + const match = text.match(/^(.*?)(\S+)(\s+)(\S+)(\s*)$/u); + if (!match) return text; + + const [, prefix, first, , second, trailing] = match; + if (first.toLocaleLowerCase() !== second.toLocaleLowerCase()) return text; + // Only collapse a duplication the tracked line already ended with — i.e. + // Gboard re-emitted the final word. Requiring a >=2-char word avoids + // eating legitimate single-letter reduplication ("a a", "i i") that a + // user may genuinely type inside the replacement window. + if (first.length < 2) return text; + if (!previousLine.trimEnd().toLocaleLowerCase().endsWith(first.toLocaleLowerCase())) { + return text; + } + return `${prefix}${first}${trailing}`; +} + +function replacementLineForMobileInput( + currentLine: string, + incoming: string, +): string | null { + if (!currentLine || currentLine.length < 2 || !incoming) return null; + + const currentLower = currentLine.toLocaleLowerCase(); + const incomingLower = incoming.toLocaleLowerCase(); + + if (incomingLower.startsWith(currentLower)) { + return collapseDuplicatedFinalWord(incoming, currentLine); + } + + const word = lastWordMatch(currentLine); + if (!word) return null; + + const [, prefix, last, trailing] = word; + if (trailing) return null; + + const incomingFirst = incoming.trimStart().split(/\s+/u)[0] ?? ""; + if ( + incomingFirst && + incomingFirst.toLocaleLowerCase() === last.toLocaleLowerCase() + ) { + return `${prefix}${collapseDuplicatedFinalWord(incoming, currentLine)}`; + } + + return null; +} + +export function shouldTreatInputAsMobileReplacement( + inputType: string | undefined, + data: string | null | undefined, + isMobileLike: boolean, +): boolean { + if ( + inputType === "insertReplacementText" || + inputType === "insertFromComposition" || + inputType === "insertCompositionText" + ) { + return true; + } + return isMobileLike && inputType === "insertText" && (data?.length ?? 0) > 1; +} + +export function updatePtyInputLine(currentLine: string, data: string): string { + // Escape sequences (arrow keys, home/end, function keys, paste guards) + // move the cursor or edit the line in ways this flat tracker cannot + // model — and the per-char loop below would append their printable + // payload (e.g. the "[D" of a left-arrow) as if it were typed text. + // Reset instead: an unknown cursor position must disarm replacement + // normalization until the user starts a fresh, cleanly-tracked line. + if (data.includes("\x1b")) { + return ""; + } + let next = currentLine; + for (const ch of chars(data)) { + if (ch === "\r" || ch === "\n") { + next = ""; + } else if (ch === DELETE || ch === "\b") { + next = removeLastChar(next); + } else if (ch === "\x15") { + next = ""; + } else if (isPlainText(ch)) { + next += ch; + } + } + return next; +} + +export function normalizePtyMobileInput( + data: string, + currentLine: string, + replacementActive: boolean, +): { data: string; nextLine: string; normalized: boolean } { + if (replacementActive && isPlainText(data)) { + const replacementLine = replacementLineForMobileInput(currentLine, data); + if (replacementLine !== null) { + return { + data: DELETE.repeat(chars(currentLine).length) + replacementLine, + nextLine: replacementLine, + normalized: true, + }; + } + } + + return { + data, + nextLine: updatePtyInputLine(currentLine, data), + normalized: false, + }; +} diff --git a/web/src/lib/pty-reconnect.test.ts b/web/src/lib/pty-reconnect.test.ts new file mode 100644 index 000000000000..48543a654024 --- /dev/null +++ b/web/src/lib/pty-reconnect.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { + shouldBlockPtyInput, + shouldReconnectPtyOnPageResume, +} from "./pty-reconnect"; + +describe("shouldReconnectPtyOnPageResume", () => { + it("reconnects a missing socket when the active page becomes visible", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: null, + ptyState: "reconnecting", + }), + ).toBe(true); + }); + + it("reconnects closed or closing sockets on visible resume", () => { + for (const socketReadyState of [2, 3]) { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState, + ptyState: "reconnecting", + }), + ).toBe(true); + } + }); + + it("does not reconnect an open socket on visible resume", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: 1, + ptyState: "open", + }), + ).toBe(false); + }); + + it("reconnects a still-connecting socket when the page is already in reconnecting state", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: 0, + ptyState: "reconnecting", + }), + ).toBe(true); + }); + + it("does not reconnect while the page is hidden", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "hidden", + online: true, + socketReadyState: 3, + ptyState: "reconnecting", + }), + ).toBe(false); + }); + + it("defers reconnect while offline", () => { + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: false, + socketReadyState: 3, + ptyState: "reconnecting", + }), + ).toBe(false); + }); + + it("does not fire a redundant reconnect while a connect is in flight (wsRef not yet assigned)", () => { + // The async socket-open IIFE has begun but not yet assigned wsRef, so + // socketReadyState reads null. Without the connectInFlight guard this + // would return true and double-connect. + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: null, + ptyState: "connecting", + connectInFlight: true, + }), + ).toBe(false); + }); + + it("still reconnects an in-flight connect when the page already believes it is closed", () => { + // A stuck attempt the user is actively trying to recover (manual reconnect + // or a closed state) must not be suppressed by the in-flight guard. + expect( + shouldReconnectPtyOnPageResume({ + isActive: true, + visibilityState: "visible", + online: true, + socketReadyState: null, + ptyState: "closed", + connectInFlight: true, + }), + ).toBe(true); + }); +}); + +describe("shouldBlockPtyInput", () => { + it("allows input only while the PTY socket is open", () => { + expect(shouldBlockPtyInput("open")).toBe(false); + expect(shouldBlockPtyInput("connecting")).toBe(true); + expect(shouldBlockPtyInput("reconnecting")).toBe(true); + expect(shouldBlockPtyInput("closed")).toBe(true); + expect(shouldBlockPtyInput("ended")).toBe(true); + }); +}); diff --git a/web/src/lib/pty-reconnect.ts b/web/src/lib/pty-reconnect.ts new file mode 100644 index 000000000000..8af050493dd8 --- /dev/null +++ b/web/src/lib/pty-reconnect.ts @@ -0,0 +1,76 @@ +export type PtyConnectionState = + | "connecting" + | "open" + | "reconnecting" + | "closed" + | "ended"; + +export const PTY_RECONNECT_INPUT_MESSAGE = + "Chat is reconnecting. Input will resume when connected."; + +// Minimum gap (ms) between page-resume-triggered reconnect attempts, so a +// burst of visibilitychange/pageshow/focus/online events on tab-return +// collapses into a single reconnect. +export const PTY_RESUME_RECONNECT_THROTTLE_MS = 1000; + +// If a socket sits in WS_CONNECTING past this budget it is treated as wedged +// (e.g. a half-open mobile socket after a radio handoff — the NS-591 case) +// and force-closed so `onclose` → scheduleReconnect can recover it. +export const PTY_CONNECTING_TIMEOUT_MS = 8000; + +export interface PtyResumeReconnectInput { + isActive: boolean; + visibilityState?: DocumentVisibilityState; + online: boolean; + socketReadyState?: number | null; + ptyState: PtyConnectionState; + connectInFlight?: boolean; +} + +const WS_CONNECTING = 0; +const WS_OPEN = 1; +const WS_CLOSING = 2; +const WS_CLOSED = 3; + +export function shouldReconnectPtyOnPageResume({ + isActive, + visibilityState, + online, + socketReadyState, + ptyState, + connectInFlight, +}: PtyResumeReconnectInput): boolean { + if (!isActive || !online || visibilityState === "hidden") { + return false; + } + if (ptyState === "ended") { + return false; + } + if (socketReadyState === WS_OPEN) { + return false; + } + // A connect is mid-flight (the async socket-open IIFE is awaiting its + // ticket URL and hasn't assigned wsRef yet, or the socket is still + // CONNECTING on a non-stuck attempt). Don't fire a redundant reconnect + // into that window unless the tab already believes it is reconnecting or + // closed and needs a fresh attempt. + if ( + (connectInFlight || socketReadyState === WS_CONNECTING) && + ptyState !== "reconnecting" && + ptyState !== "closed" + ) { + return false; + } + return ( + socketReadyState === null || + socketReadyState === undefined || + socketReadyState === WS_CLOSING || + socketReadyState === WS_CLOSED || + ptyState === "reconnecting" || + ptyState === "closed" + ); +} + +export function shouldBlockPtyInput(ptyState: PtyConnectionState): boolean { + return ptyState !== "open"; +} diff --git a/web/src/lib/reasoning-effort.test.ts b/web/src/lib/reasoning-effort.test.ts index 3ade00347245..9c2d0b139aef 100644 --- a/web/src/lib/reasoning-effort.test.ts +++ b/web/src/lib/reasoning-effort.test.ts @@ -14,7 +14,7 @@ describe("normalizeEffort", () => { }); it("passes through every valid effort level", () => { - for (const level of ["none", "minimal", "low", "medium", "high", "xhigh"]) { + for (const level of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]) { expect(normalizeEffort(level)).toBe(level); } }); @@ -26,7 +26,6 @@ describe("normalizeEffort", () => { it("falls back to medium for unknown values", () => { expect(normalizeEffort("turbo")).toBe("medium"); - expect(normalizeEffort("max")).toBe("medium"); // 'max' is a label, not a value expect(normalizeEffort(42)).toBe("medium"); }); }); @@ -41,7 +40,7 @@ describe("EFFORT_OPTIONS", () => { it("covers the real reasoning levels plus thinking-off", () => { // Invariant against hermes_constants.VALID_REASONING_EFFORTS + 'none'. const values = new Set(EFFORT_OPTIONS.map((o) => o.value)); - for (const level of ["none", "minimal", "low", "medium", "high", "xhigh"]) { + for (const level of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]) { expect(values.has(level)).toBe(true); } }); diff --git a/web/src/lib/reasoning-effort.ts b/web/src/lib/reasoning-effort.ts index 1e8313e04891..2d5fecd31207 100644 --- a/web/src/lib/reasoning-effort.ts +++ b/web/src/lib/reasoning-effort.ts @@ -20,7 +20,9 @@ export const EFFORT_OPTIONS: ReadonlyArray = [ { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, - { value: "xhigh", label: "Max" }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + { value: "ultra", label: "Ultra" }, ]; export const VALID_EFFORTS: ReadonlySet = new Set( diff --git a/web/src/lib/session-import.test.ts b/web/src/lib/session-import.test.ts new file mode 100644 index 000000000000..04f07debd988 --- /dev/null +++ b/web/src/lib/session-import.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { importSummary, parseImportSessions } from "./session-import"; + +describe("parseImportSessions", () => { + it("accepts a single exported session", () => { + expect(parseImportSessions('{"id":"session-1","messages":[]}')).toEqual([ + { id: "session-1", messages: [] }, + ]); + }); + + it("accepts arrays and wrapped session exports", () => { + const sessions = [{ id: "one" }, { id: "two" }]; + expect(parseImportSessions(JSON.stringify(sessions))).toEqual(sessions); + expect(parseImportSessions(JSON.stringify({ sessions }))).toEqual(sessions); + }); + + it("accepts JSONL session exports", () => { + expect(parseImportSessions('{"id":"one"}\n\n{"id":"two"}\n')).toEqual([ + { id: "one" }, + { id: "two" }, + ]); + }); + + it("rejects empty files and non-object entries", () => { + expect(() => parseImportSessions(" \n")).toThrow("File is empty"); + expect(() => parseImportSessions('[{"id":"one"},42]')).toThrow( + "Expected exported session JSON or JSONL", + ); + }); +}); + +describe("importSummary", () => { + it("includes skipped and detached counts only when present", () => { + expect( + importSummary({ + ok: true, + imported: 2, + skipped: 1, + detached: 1, + imported_ids: ["one", "two"], + skipped_ids: ["existing"], + errors: [], + }), + ).toBe("2 imported; 1 skipped; 1 detached from missing parents"); + }); +}); diff --git a/web/src/lib/session-import.ts b/web/src/lib/session-import.ts new file mode 100644 index 000000000000..49d09fd67c80 --- /dev/null +++ b/web/src/lib/session-import.ts @@ -0,0 +1,46 @@ +import type { SessionImportResponse } from "@/lib/api"; + +export type ImportableSession = Record; + +function normalizeImportSessions(value: unknown): ImportableSession[] { + const candidate = + value && + typeof value === "object" && + !Array.isArray(value) && + Array.isArray((value as { sessions?: unknown }).sessions) + ? (value as { sessions: unknown[] }).sessions + : Array.isArray(value) + ? value + : [value]; + + const sessions = candidate.filter( + (item): item is ImportableSession => + !!item && typeof item === "object" && !Array.isArray(item), + ); + if (sessions.length !== candidate.length) { + throw new Error("Expected exported session JSON or JSONL"); + } + return sessions; +} + +export function parseImportSessions(text: string): ImportableSession[] { + const trimmed = text.trim(); + if (!trimmed) throw new Error("File is empty"); + + try { + return normalizeImportSessions(JSON.parse(trimmed)); + } catch (jsonError) { + const lines = trimmed.split(/\r?\n/).filter((line) => line.trim()); + if (lines.length <= 1) throw jsonError; + return normalizeImportSessions(lines.map((line) => JSON.parse(line))); + } +} + +export function importSummary(result: SessionImportResponse): string { + const parts = [`${result.imported} imported`]; + if (result.skipped > 0) parts.push(`${result.skipped} skipped`); + if (result.detached > 0) { + parts.push(`${result.detached} detached from missing parents`); + } + return parts.join("; "); +} diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index 061468e15cab..625fd9bccf4b 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -336,7 +336,11 @@ export default function ChannelsPage() { {editing && (
e.target === e.currentTarget && setEditing(null)} role="dialog" aria-modal="true" @@ -345,7 +349,7 @@ export default function ChannelsPage() {
+
+
+ )} + {/* NS-504: the agent process exited (e.g. `/exit` or a new session). Offer an in-place restart so the user never has to refresh the whole page to get a working chat back. */} - {sessionEnded && ( -
+ {ptyState === "ended" && ( +
Session ended.
+ <> +
+ + setUrl(e.target.value)} + /> +
+
+ + +
+ {httpAuth === "header" && ( +
+ + setBearerToken(e.target.value)} + /> +

+ Stored in this profile's .env; config.yaml keeps + only an environment-variable reference. +

+
+ )} + {httpAuth === "oauth" && ( +

+ Add the server, then use Authenticate. Hermes opens the + OAuth browser on the machine running the Dashboard + backend. +

+ )} + ) : ( <>
@@ -421,20 +469,21 @@ export default function McpPage() { onChange={(e) => setArgs(e.target.value)} />
+
+ +