diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 2c195e9d6..18f9324bb 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -10,6 +10,9 @@ reviews: auto_review: enabled: true drafts: false + # Review once when a PR opens; ask again with `@coderabbitai review` before merging, + # so each push does not spend the per-developer review allowance. + auto_incremental_review: false path_filters: - '!shared/herdr-api.generated.ts' - '!docs/screenshots/**' diff --git a/.github/REVIEW.md b/.github/REVIEW.md index d5b90fe38..0badf8ba6 100644 --- a/.github/REVIEW.md +++ b/.github/REVIEW.md @@ -20,13 +20,20 @@ AI review is advisory; CI and maintainer review determine whether a PR can merge - A malformed VAPID file must fail without rotating the key. The service worker must show a notification for every push, including while the app is visible. - Preserve IME and native clipboard input. Use existing theme tokens for component CSS. +- The `conventions.test.ts` baselines only shrink, and CI's shallow checkout cannot compare them + with main: flag a PR that raises a count in `scripts/fixed-sleeps.json` (a rename moves its entry + with the same count) or adds an allowlist entry (DEMO_404, DEMO_IGNORES, UNIX_CONNECT_ALLOWED, + NET_CONNECT_ALLOWED) without a reason that holds. - Installed updaters discover Git tags immediately. Release only the exact commit that passed CI; never create the release tag as a prerequisite for validation. - External contributors' PRs get the same priority and the same bar as maintainers' own: review them when they arrive and consider them for the next release. Approve their CI runs only after reading everything CI executes: workflows, scripts, tests, `package.json` scripts and dependency changes. Workflows must keep read-only PR - permissions and never add a `pull_request_target` trigger. + permissions and never add a `pull_request_target` trigger. Never approve the run of a fork PR + that changes `runs-on`, adds self-hosted labels, or edits `.github/actions/` or + `scripts/ci-runner-route.sh`: a self-hosted runner executes an approved fork workflow only if a + workflow routes it there. [CONTRIBUTING.md](../CONTRIBUTING.md) is the process contributors follow. The [AGENTS.md](../AGENTS.md) files give these invariants with their reasons and the conventions diff --git a/.github/actions/setup-ci/action.yml b/.github/actions/setup-ci/action.yml new file mode 100644 index 000000000..c093136af --- /dev/null +++ b/.github/actions/setup-ci/action.yml @@ -0,0 +1,66 @@ +name: Set up CI toolchain +description: Bun (with its dependency cache), and optionally Node 22 and the checksum-pinned herdr. Callers check out the repository first. +inputs: + bun-version: + description: Bun version for ci.yml, pages.yml and release.yml; the one place it is set. + required: false + default: '1.4.2' + node: + description: "'true' installs Node 22 (the PTY sidecar runs on it)." + required: false + default: 'false' + herdr: + description: "'true' installs the checksum-pinned herdr 0.9.3 (Linux x86_64) on PATH." + required: false + default: 'false' +runs: + using: composite + steps: + # Keep cached files inside the checkout. Home-directory archives contain relative paths + # that cannot be restored between GitHub's workspace and the image's shorter /w path. + - name: Select workspace-local Bun cache + shell: bash + env: + BUN_CACHE_DIR: ${{ github.workspace }}/.ci-cache/bun + run: printf 'BUN_INSTALL_CACHE_DIR=%s\n' "$BUN_CACHE_DIR" >> "$GITHUB_ENV" + - name: Select workspace-local hosted browser cache + if: runner.environment != 'self-hosted' + shell: bash + env: + BROWSER_CACHE_DIR: ${{ github.workspace }}/.ci-cache/playwright + run: printf 'PLAYWRIGHT_BROWSERS_PATH=%s\n' "$BROWSER_CACHE_DIR" >> "$GITHUB_ENV" + - name: Verify prepared self-hosted tools + if: runner.environment == 'self-hosted' + shell: bash + env: + EXPECTED_BUN_VERSION: ${{ inputs.bun-version }} + NEED_NODE: ${{ inputs.node }} + NEED_HERDR: ${{ inputs.herdr }} + run: bash scripts/ci-preinstalled-tools.sh + - uses: oven-sh/setup-bun@v2 + if: runner.environment != 'self-hosted' + with: + bun-version: ${{ inputs.bun-version }} + - if: inputs.node == 'true' && runner.environment != 'self-hosted' + uses: actions/setup-node@v7 + with: + node-version: '22' + - name: Cache Bun dependencies + # On the measured self-hosted connection this archive costs 37–43s to restore, + # versus 7–20s for a fresh install. Do not pay for restore/save on disposable runners. + if: runner.environment != 'self-hosted' + uses: actions/cache@v4 + with: + path: .ci-cache/bun + key: bun-workspace-v2-${{ runner.environment }}-${{ runner.os }}-${{ runner.arch }}-${{ inputs.bun-version }}-${{ hashFiles('bun.lock') }} + restore-keys: | + bun-workspace-v2-${{ runner.environment }}-${{ runner.os }}-${{ runner.arch }}-${{ inputs.bun-version }}- + - name: Install checksum-pinned herdr 0.9.3 + if: inputs.herdr == 'true' && runner.environment != 'self-hosted' + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/herdr-bin" + curl --fail --location --retry 3 https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-linux-x86_64 -o "$RUNNER_TEMP/herdr-bin/herdr" + echo "18a8dc65f1c2fa485884344356dea1cfd911c6f06cf46fa78e193f4087f4dba7 $RUNNER_TEMP/herdr-bin/herdr" | sha256sum --check + chmod +x "$RUNNER_TEMP/herdr-bin/herdr" + echo "$RUNNER_TEMP/herdr-bin" >> "$GITHUB_PATH" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4c6bd2605..438511919 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,4 @@ - + ## Change diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddad51ec2..3fd00bffd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,15 @@ on: push: branches: [main] workflow_dispatch: + inputs: + self_hosted_labels: + description: 'Runner labels (JSON array containing "self-hosted") for this run; empty uses CI_SELF_HOSTED_LABELS' + required: false + default: '' + type: string workflow_call: permissions: contents: read -env: - BUN_VERSION: 1.4.2 concurrency: group: validation-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -22,31 +26,46 @@ jobs: outputs: code: ${{ steps.filter.outputs.code }} draft: ${{ steps.filter.outputs.draft }} + linux: ${{ steps.route.outputs.linux }} steps: - uses: actions/checkout@v7 with: ref: ${{ github.sha }} fetch-depth: 0 + - id: route + name: Choose the Linux runner + env: + EVENT_NAME: ${{ github.event_name }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + REPOSITORY: ${{ github.repository }} + REQUESTED_LABELS: ${{ inputs.self_hosted_labels }} + CONFIGURED_LABELS: ${{ vars.CI_SELF_HOSTED_LABELS }} + run: bash scripts/ci-runner-route.sh >> "$GITHUB_OUTPUT" - id: filter + env: + EVENT_NAME: ${{ github.event_name }} + PR_DRAFT: ${{ github.event.pull_request.draft }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | - # Draft PR detection - if [ "${{ github.event.pull_request.draft }}" = "true" ]; then + if [ "$PR_DRAFT" = "true" ]; then echo "draft=true" >> "$GITHUB_OUTPUT" else echo "draft=false" >> "$GITHUB_OUTPUT" fi - if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then + # Only a pull request may skip the heavy jobs as docs-only. Every other event validates in + # full, including workflow_call from release.yml and manual pages.yml dispatches, where github.event_name is + # the caller's event and a docs-only last commit must not skip release validation. + if [ "$EVENT_NAME" != "pull_request" ] || [ -z "$BASE_SHA" ]; then echo "code=true" >> "$GITHUB_OUTPUT" exit 0 fi - BASE="${{ github.event.pull_request.base.sha }}" - if [ -z "$BASE" ]; then BASE="HEAD~1"; fi - CHANGED=$(git diff --name-only "$BASE" HEAD || echo "") + CHANGED=$(git diff --name-only "$BASE_SHA" HEAD || echo "") if [ -z "$CHANGED" ]; then echo "code=true" >> "$GITHUB_OUTPUT" exit 0 fi - if echo "$CHANGED" | grep -qvE '^(docs/|\.md$|\.txt$|LICENSE|\.github/ISSUE_TEMPLATE/)'; then + # Docs-only: under docs/, any *.md or *.txt, the root LICENSE, or an issue template. + if echo "$CHANGED" | grep -qvE '^docs/|\.(md|txt)$|^LICENSE$|^\.github/ISSUE_TEMPLATE/'; then echo "code=true" >> "$GITHUB_OUTPUT" else echo "code=false" >> "$GITHUB_OUTPUT" @@ -64,16 +83,7 @@ jobs: - uses: actions/checkout@v7 with: ref: ${{ github.sha }} - - uses: oven-sh/setup-bun@v2 - with: - bun-version: ${{ env.BUN_VERSION }} - - name: Cache Bun dependencies - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: bun-windows-${{ runner.arch }}-${{ env.BUN_VERSION }}-${{ hashFiles('bun.lock') }} - restore-keys: | - bun-windows-${{ runner.arch }}-${{ env.BUN_VERSION }}- + - uses: ./.github/actions/setup-ci # A required check: a draft run must not report it green without running it, or a stale # draft result could satisfy the merge gate. Marking the PR ready reruns CI. - name: Fail on draft PRs (Windows tests not run) @@ -103,16 +113,7 @@ jobs: - uses: actions/checkout@v7 with: ref: ${{ github.sha }} - - uses: oven-sh/setup-bun@v2 - with: - bun-version: ${{ env.BUN_VERSION }} - - name: Cache Bun dependencies - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: bun-macos-${{ runner.arch }}-${{ env.BUN_VERSION }}-${{ hashFiles('bun.lock') }} - restore-keys: | - bun-macos-${{ runner.arch }}-${{ env.BUN_VERSION }}- + - uses: ./.github/actions/setup-ci - name: Skip heavy tests on draft PRs if: needs.changes.outputs.draft == 'true' run: | @@ -125,7 +126,7 @@ jobs: fast: name: Fast checks needs: changes - runs-on: ubuntu-24.04 + runs-on: ${{ fromJSON(needs.changes.outputs.linux) }} timeout-minutes: 10 env: CHECK_DIR: ${{ github.workspace }}/.ci @@ -134,21 +135,22 @@ jobs: - uses: actions/checkout@v7 with: ref: ${{ github.sha }} - - uses: oven-sh/setup-bun@v2 - with: - bun-version: ${{ env.BUN_VERSION }} - - uses: actions/setup-node@v7 + - uses: ./.github/actions/setup-ci with: - node-version: '22' - - name: Cache Bun dependencies - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: bun-linux-${{ runner.arch }}-${{ env.BUN_VERSION }}-${{ hashFiles('bun.lock') }} - restore-keys: | - bun-linux-${{ runner.arch }}-${{ env.BUN_VERSION }}- + node: 'true' - run: bun install --frozen-lockfile - run: bun run check fast + # A release (package.json's version not tagged yet) must raise REMOTE_BUNDLE_VERSION when the + # bundle's inputs changed, record the telemetry Worker deploy and carry its patch notes. Only the + # change that bumps the version against BASE_SHA counts, so PRs merged before the Release run + # pass. The checkout is shallow, so the tags and the base it compares with are fetched first. + - name: Release guards + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + git fetch --no-tags --depth=1 origin '+refs/tags/v*:refs/tags/v*' '+refs/tags/remote-v*:refs/tags/remote-v*' + if [ -n "$BASE_SHA" ]; then git fetch --no-tags --depth=1 origin "$BASE_SHA" || true; fi + bun scripts/release-guards.ts --base "$BASE_SHA" - name: Preserve check report and verification logs if: always() uses: actions/upload-artifact@v7 @@ -166,44 +168,36 @@ jobs: name: Integration tests needs: changes if: needs.changes.outputs.code == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 15 + runs-on: ${{ fromJSON(needs.changes.outputs.linux) }} + # a wait for a slot counts against it + timeout-minutes: 50 env: CHECK_DIR: ${{ github.workspace }}/.ci + # The herdr-backed lanes of every runner on the self-hosted PC share check.ts's slots + # (scripts/self-hosted-runner/run.sh): at most CHECK_MAX_RUNS at once, and a lane that finds + # them taken waits its turn instead of failing. A hosted runner has the slots to itself. + CHECK_MAX_RUNS: 3 + CHECK_WAIT_SECONDS: 2400 steps: - uses: actions/checkout@v7 with: ref: ${{ github.sha }} - - uses: oven-sh/setup-bun@v2 - with: - bun-version: ${{ env.BUN_VERSION }} - - uses: actions/setup-node@v7 - with: - node-version: '22' - - name: Cache Bun dependencies - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: bun-linux-${{ runner.arch }}-${{ env.BUN_VERSION }}-${{ hashFiles('bun.lock') }} - restore-keys: | - bun-linux-${{ runner.arch }}-${{ env.BUN_VERSION }}- - name: Skip heavy tests on draft PRs if: needs.changes.outputs.draft == 'true' run: | echo "Draft PR detected, skipping integration tests" exit 0 - - name: Install checksum-pinned herdr 0.9.3 + - uses: ./.github/actions/setup-ci if: needs.changes.outputs.draft != 'true' - run: | - mkdir -p "$RUNNER_TEMP/herdr-bin" - curl --fail --location --retry 3 https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-linux-x86_64 -o "$RUNNER_TEMP/herdr-bin/herdr" - echo "18a8dc65f1c2fa485884344356dea1cfd911c6f06cf46fa78e193f4087f4dba7 $RUNNER_TEMP/herdr-bin/herdr" | sha256sum --check - chmod +x "$RUNNER_TEMP/herdr-bin/herdr" - echo "$RUNNER_TEMP/herdr-bin" >> "$GITHUB_PATH" + with: + node: 'true' + herdr: 'true' - run: bun install --frozen-lockfile if: needs.changes.outputs.draft != 'true' - name: Integration tests (updater restart and rollback included) if: needs.changes.outputs.draft != 'true' + env: + HERDR_TEST_SHARDS: ${{ runner.environment == 'self-hosted' && '4' || '1' }} run: bun run check integration - name: Preserve check report and verification logs if: always() @@ -220,40 +214,30 @@ jobs: name: Browser regression (ui-regression) needs: changes if: needs.changes.outputs.code == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 15 + runs-on: ${{ fromJSON(needs.changes.outputs.linux) }} + # a wait for a slot counts against it + timeout-minutes: 50 env: CHECK_DIR: ${{ github.workspace }}/.ci + # The herdr-backed lanes of every runner on the self-hosted PC share check.ts's slots + # (scripts/self-hosted-runner/run.sh): at most CHECK_MAX_RUNS at once, and a lane that finds + # them taken waits its turn instead of failing. A hosted runner has the slots to itself. + CHECK_MAX_RUNS: 3 + CHECK_WAIT_SECONDS: 2400 steps: - uses: actions/checkout@v7 with: ref: ${{ github.sha }} - - uses: oven-sh/setup-bun@v2 - with: - bun-version: ${{ env.BUN_VERSION }} - - uses: actions/setup-node@v7 - with: - node-version: '22' - - name: Cache Bun dependencies - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: bun-linux-${{ runner.arch }}-${{ env.BUN_VERSION }}-${{ hashFiles('bun.lock') }} - restore-keys: | - bun-linux-${{ runner.arch }}-${{ env.BUN_VERSION }}- - name: Skip heavy tests on draft PRs if: needs.changes.outputs.draft == 'true' run: | echo "Draft PR detected, skipping browser-main tests" exit 0 - - name: Install checksum-pinned herdr 0.9.3 + - uses: ./.github/actions/setup-ci if: needs.changes.outputs.draft != 'true' - run: | - mkdir -p "$RUNNER_TEMP/herdr-bin" - curl --fail --location --retry 3 https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-linux-x86_64 -o "$RUNNER_TEMP/herdr-bin/herdr" - echo "18a8dc65f1c2fa485884344356dea1cfd911c6f06cf46fa78e193f4087f4dba7 $RUNNER_TEMP/herdr-bin/herdr" | sha256sum --check - chmod +x "$RUNNER_TEMP/herdr-bin/herdr" - echo "$RUNNER_TEMP/herdr-bin" >> "$GITHUB_PATH" + with: + node: 'true' + herdr: 'true' - run: bun install --frozen-lockfile if: needs.changes.outputs.draft != 'true' - name: Get Playwright version @@ -261,18 +245,19 @@ jobs: id: playwright-version run: echo "version=$(bun -e 'console.log(require("playwright-core/package.json").version)')" >> "$GITHUB_OUTPUT" - name: Cache Playwright browser - if: needs.changes.outputs.draft != 'true' + if: needs.changes.outputs.draft != 'true' && runner.environment != 'self-hosted' uses: actions/cache@v4 with: - path: ~/.cache/ms-playwright - key: playwright-linux-${{ runner.arch }}-${{ steps.playwright-version.outputs.version }} + path: .ci-cache/playwright + key: playwright-workspace-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright-version.outputs.version }} - run: bun run build if: needs.changes.outputs.draft != 'true' - - name: Browser regression (ui-regression.ts) + - name: Browser regression (UI suites) if: needs.changes.outputs.draft != 'true' env: - BROWSER_SCRIPTS: scripts/ui-regression.ts - run: bun scripts/ci-browser-parallel.ts + BROWSER_SCRIPTS: scripts/ui-regression.ts,scripts/ui-contexts-regression.ts + BROWSER_WORKERS: 2 + run: bun run check run -- bun scripts/ci-browser-parallel.ts - name: Preserve browser logs and evidence if: always() uses: actions/upload-artifact@v7 @@ -289,41 +274,30 @@ jobs: name: Browser regression (other scripts) needs: changes if: needs.changes.outputs.code == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 15 + runs-on: ${{ fromJSON(needs.changes.outputs.linux) }} + # a wait for a slot counts against it + timeout-minutes: 50 env: CHECK_DIR: ${{ github.workspace }}/.ci - BROWSER_WORKERS: 4 + # The herdr-backed lanes of every runner on the self-hosted PC share check.ts's slots + # (scripts/self-hosted-runner/run.sh): at most CHECK_MAX_RUNS at once, and a lane that finds + # them taken waits its turn instead of failing. A hosted runner has the slots to itself. + CHECK_MAX_RUNS: 3 + CHECK_WAIT_SECONDS: 2400 steps: - uses: actions/checkout@v7 with: ref: ${{ github.sha }} - - uses: oven-sh/setup-bun@v2 - with: - bun-version: ${{ env.BUN_VERSION }} - - uses: actions/setup-node@v7 - with: - node-version: '22' - - name: Cache Bun dependencies - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: bun-linux-${{ runner.arch }}-${{ env.BUN_VERSION }}-${{ hashFiles('bun.lock') }} - restore-keys: | - bun-linux-${{ runner.arch }}-${{ env.BUN_VERSION }}- - name: Skip heavy tests on draft PRs if: needs.changes.outputs.draft == 'true' run: | echo "Draft PR detected, skipping browser-other tests" exit 0 - - name: Install checksum-pinned herdr 0.9.3 + - uses: ./.github/actions/setup-ci if: needs.changes.outputs.draft != 'true' - run: | - mkdir -p "$RUNNER_TEMP/herdr-bin" - curl --fail --location --retry 3 https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-linux-x86_64 -o "$RUNNER_TEMP/herdr-bin/herdr" - echo "18a8dc65f1c2fa485884344356dea1cfd911c6f06cf46fa78e193f4087f4dba7 $RUNNER_TEMP/herdr-bin/herdr" | sha256sum --check - chmod +x "$RUNNER_TEMP/herdr-bin/herdr" - echo "$RUNNER_TEMP/herdr-bin" >> "$GITHUB_PATH" + with: + node: 'true' + herdr: 'true' - run: bun install --frozen-lockfile if: needs.changes.outputs.draft != 'true' - name: Get Playwright version @@ -331,19 +305,20 @@ jobs: id: playwright-version run: echo "version=$(bun -e 'console.log(require("playwright-core/package.json").version)')" >> "$GITHUB_OUTPUT" - name: Cache Playwright browser - if: needs.changes.outputs.draft != 'true' + if: needs.changes.outputs.draft != 'true' && runner.environment != 'self-hosted' uses: actions/cache@v4 with: - path: ~/.cache/ms-playwright - key: playwright-linux-${{ runner.arch }}-${{ steps.playwright-version.outputs.version }} + path: .ci-cache/playwright + key: playwright-workspace-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright-version.outputs.version }} - run: bun run build if: needs.changes.outputs.draft != 'true' - name: Browser regression (parallel) if: needs.changes.outputs.draft != 'true' env: - # Every script of scripts/ci-browser.sh except the one browser-main runs. - BROWSER_EXCLUDE: scripts/ui-regression.ts - run: bun scripts/ci-browser-parallel.ts + # Every script of scripts/ci-browser.sh except the two browser-main runs. + BROWSER_EXCLUDE: scripts/ui-regression.ts,scripts/ui-contexts-regression.ts + BROWSER_WORKERS: ${{ runner.environment == 'self-hosted' && '6' || '4' }} + run: bun run check run -- bun scripts/ci-browser-parallel.ts - name: Preserve browser logs and evidence if: always() uses: actions/upload-artifact@v7 @@ -364,26 +339,32 @@ jobs: timeout-minutes: 5 steps: - name: Check results + env: + CODE: ${{ needs.changes.outputs.code }} + DRAFT: ${{ needs.changes.outputs.draft }} + INTEGRATION_RESULT: ${{ needs.integration.result }} + BROWSER_MAIN_RESULT: ${{ needs.browser-main.result }} + BROWSER_OTHER_RESULT: ${{ needs.browser-other.result }} run: | - if [ "${{ needs.changes.outputs.code }}" = "false" ]; then + if [ "$CODE" = "false" ]; then echo "Skipped: only documentation changes" exit 0 fi # A required check: a draft run skipped the heavy tests, so it must not report green, # or a stale draft result could satisfy the merge gate. Marking the PR ready reruns CI. - if [ "${{ needs.changes.outputs.draft }}" = "true" ]; then + if [ "$DRAFT" = "true" ]; then echo "::error::Draft PR: integration and browser tests did not run. Mark the PR ready for review to run them." exit 1 fi - if [ "${{ needs.integration.result }}" != "success" ]; then + if [ "$INTEGRATION_RESULT" != "success" ]; then echo "Integration tests failed" exit 1 fi - if [ "${{ needs.browser-main.result }}" != "success" ]; then + if [ "$BROWSER_MAIN_RESULT" != "success" ]; then echo "Browser regression (ui-regression) failed" exit 1 fi - if [ "${{ needs.browser-other.result }}" != "success" ]; then + if [ "$BROWSER_OTHER_RESULT" != "success" ]; then echo "Browser regression (other scripts) failed" exit 1 fi diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5c3184524..a74ffc312 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,10 +1,12 @@ name: Website -# Builds site/ into the GitHub Pages site (https://herdrweb.dev/) on every push to +# Builds site/ into the GitHub Pages site (https://herdrweb.dev/) after CI succeeds for a push to # main: the page, and the browser demo, which is the app itself, so any client change is a site # change. README.md is a source too: the page's top video and feature clips are the README's # (docs/development.md, "README media"). on: - push: + workflow_run: + workflows: [CI] + types: [completed] branches: [main] workflow_dispatch: permissions: @@ -15,13 +17,19 @@ concurrency: jobs: validate: name: Validate source - if: github.ref == 'refs/heads/main' + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' uses: ./.github/workflows/ci.yml permissions: contents: read build: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-24.04 + if: >- + github.ref == 'refs/heads/main' && + (github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository)) + runs-on: ${{ vars.CI_SELF_HOSTED_LABELS && fromJSON(vars.CI_SELF_HOSTED_LABELS) || 'ubuntu-24.04' }} permissions: contents: read outputs: @@ -29,25 +37,40 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ github.sha }} - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.2 - # poster frames and scaled stills (scripts/build-site.ts); the runner image has no ffmpeg - - run: sudo bash scripts/ci-bounded-retry.sh 600 bash -c 'apt-get update -qq && apt-get install -y -qq --no-install-recommends ffmpeg' + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + - uses: ./.github/actions/setup-ci + # poster frames and scaled stills (scripts/build-site.ts); the GitHub-hosted image has no + # ffmpeg, the self-hosted runner image ships it and has no sudo + - run: | + if command -v ffmpeg >/dev/null 2>&1; then + echo "ffmpeg already installed: $(command -v ffmpeg)" + else + sudo bash scripts/ci-bounded-retry.sh 600 bash -c 'apt-get update -qq && apt-get install -y -qq --no-install-recommends ffmpeg' + fi # the demo is a Vite build of the client - run: bun install --frozen-lockfile - run: bun scripts/build-site.ts - id: source name: Record built SHA - run: echo "sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - uses: actions/upload-pages-artifact@v5 with: path: _site deploy: - if: github.ref == 'refs/heads/main' + if: >- + always() && github.ref == 'refs/heads/main' && + needs.build.result == 'success' && + (needs.validate.result == 'success' || + (needs.validate.result == 'skipped' && + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success')) && + (github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository)) needs: [validate, build] - runs-on: ubuntu-24.04 + runs-on: ${{ vars.CI_SELF_HOSTED_LABELS && fromJSON(vars.CI_SELF_HOSTED_LABELS) || 'ubuntu-24.04' }} permissions: contents: read pages: write @@ -59,7 +82,7 @@ jobs: - name: Verify deployment SHA is current env: GH_TOKEN: ${{ github.token }} - EXPECTED_SHA: ${{ github.sha }} + EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} BUILT_SHA: ${{ needs.build.outputs.sha }} run: | set -euo pipefail @@ -81,9 +104,9 @@ jobs: - name: Summarize deployment if: success() env: - VALIDATED_SHA: ${{ github.sha }} + VALIDATED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} BUILT_SHA: ${{ needs.build.outputs.sha }} - DEPLOYED_SHA: ${{ github.sha }} + DEPLOYED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} run: | { printf 'Validated SHA: `%s`\n' "$VALIDATED_SHA" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 623ba85e7..b6269873d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ concurrency: cancel-in-progress: false jobs: preflight: - runs-on: ubuntu-24.04 + runs-on: ${{ vars.CI_SELF_HOSTED_LABELS && fromJSON(vars.CI_SELF_HOSTED_LABELS) || 'ubuntu-24.04' }} timeout-minutes: 5 steps: - name: Require main @@ -24,14 +24,14 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.2 + - uses: ./.github/actions/setup-ci - name: Validate version, notes and unused tag env: RELEASE_VERSION: ${{ inputs.version }} run: | bun scripts/release-notes.ts "$RELEASE_VERSION" + # the remote-vN this version names is published from this tree, and the Worker deploy is recorded + bun scripts/release-guards.ts --release "$RELEASE_VERSION" if git show-ref --verify --quiet "refs/tags/v$RELEASE_VERSION"; then echo 'This version already has a tag; choose a new version.' >&2 exit 1 @@ -41,7 +41,7 @@ jobs: uses: ./.github/workflows/ci.yml publish: needs: validate - runs-on: ubuntu-24.04 + runs-on: ${{ vars.CI_SELF_HOSTED_LABELS && fromJSON(vars.CI_SELF_HOSTED_LABELS) || 'ubuntu-24.04' }} timeout-minutes: 5 permissions: contents: write @@ -49,9 +49,7 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.2 + - uses: ./.github/actions/setup-ci - name: Publish the validated commit env: GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index 174a9f2c8..c17fbc3ce 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ evidence/ .herdr-web-ui/ # a check run's herdr config and state, when CHECK_DIR keeps it in the checkout (scripts/check.ts) .ci/ +.ci-cache/ remote-bundles/ diff --git a/AGENTS.md b/AGENTS.md index bf5c6f739..bc8c0021c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,87 +1,43 @@ # herdr-web-ui -Browser UI for the herdr terminal multiplexer: a React 18 + xterm.js client (`src/`) and a Bun.serve backend (`server/`) that bridges herdr's newline-JSON unix-socket API. `shared/` is the wire contract both sides import. - -## Scope - -- This file, `server/AGENTS.md`, `src/AGENTS.md`, `scripts/AGENTS.md` and `shared/AGENTS.md` are committed and apply to every agent working on the project, forks included. A PR is checked against them together with `CONTRIBUTING.md`, `.github/REVIEW.md`, `docs/development.md` and `DESIGN.md`. -- They hold only what the code and those documents do not show. Commands, layout, environment variables and release steps are in `docs/development.md` and `package.json`; do not copy them here. -- Read `server/AGENTS.md` before editing `server/` and `src/AGENTS.md` before editing `src/`; likewise `scripts/AGENTS.md` for `scripts/` and `shared/AGENTS.md` for `shared/`. -- "Maintainer workflow" applies only to the maintainer. Everyone else follows `CONTRIBUTING.md` for branches, PRs and the changelog. - -## Bridge invariants - -The app is only a bridge: herdr owns every pty, scrollback and agent state. - -- NEVER load node-pty inside Bun; it panics the runtime (oven-sh/bun#18546). All PTY work goes through the Node sidecar `server/pty/pty-host.mjs`, and the only process it runs is `herdr terminal attach `. -- NEVER pass `--takeover` to `herdr terminal attach` on the server's own initiative: an attach, a retry or a reconnect waits for the holder instead. Only a user's explicit request for that pane, from an interact connection, may take it. Always set `HERDR_SOCKET_PATH` to the socket the RPCs use. -- NEVER give xterm scrollback (keep `scrollback: 0`), and never rebuild an attached terminal from `pane.read`. The attach byte stream is the source of truth; only a mirrored pane (below) is drawn from `pane.read`. -- NEVER edit `shared/herdr-api.generated.ts`. Run `bun run generate:types`. -- NEVER pool herdr RPC connections: herdr closes the socket after each response. Use one connection per call (10 s timeout). Only `events.subscribe` stays open, and a second subscribe on an open connection is silently ignored, so reopen it with the full set. -- `pane.process_info` takes `pane_id`, not `target`. Given `target`, it silently answers for the focused pane. -- `server/collector.ts` is the only status subscription source. Do not subscribe to status anywhere else. -- Nothing the user typed is ever queued or sent automatically while offline. Keystrokes go to a draft the user sends or discards. An explicit chat Send while an agent works can enter the bridge's pending list on `pending-input` bridges: the next turn and its explicit Send now action claim the same server ID. Automatic delivery requires the original live connection and pane lease; disconnect/reload never resumes it. Existing browser-held messages still require "Send now". -- Prompt answers are `send_keys` navigation, never digits, and go through `POST /api/pane/prompt/answer`: the key semantics per agent live on the server. -- Web push: build requests with `generateRequestDetails` and send them with `fetch`; never call `sendNotification`. -- The omo transcript is found through the process tree, never through `pane.agent` or file mtime. -- Windows x64 has no PTY sidecar: herdr there cannot `terminal attach`, so the server mirrors the screen (`server/mirror.ts`). A Linux or macOS PC without Node for the sidecar is mirrored the same way. Do not assume a pty exists. - -## Changing a contract - -- HTTP and WS shapes live in `shared/protocol.ts`; change both sides through it and add a contract test (`server/api.contract.test.ts` for endpoints). -- Every push to `main` redeploys the site and the demo. A new endpoint or WS frame needs an answer in `site/demo/transport.ts`, or the demo gets a 404. -- The demo is the client itself: `site/demo/transport.ts` also imports `shared/` and `rollupStatus` from `src/lib/status.ts`, so a change there changes the demo. -- `site/demo/fixtures.ts` is bundled into the browser and reused by `scripts/readme-media/stage.ts`: keep it fictional and free of server imports. -- Every error body is `{ error: { code, message } }`, built only with the helpers in `server/http.ts`. -- Mutating machine, device and update POSTs require same-origin plus the `x-herdr-machine: 1` or `x-herdr-update: 1` header. -- Route order in `createServer().fetch` matters: bridge, then machines, then `/ws`, then the `/api/*` handlers, then a 404 for the rest of `/api/*`, then static files. - -## Code conventions - -- Imports carry explicit `.ts`/`.tsx` extensions and relative paths. The `@shared/*` alias is configured but unused; do not start using it. -- Wire fields are snake_case (`pane_id`) and code is camelCase (`paneId`). -- `createServer(options)` is the only injection seam; an unset option falls back to the environment. -- State files are written to a temp file and renamed, files 0600 and directories 0700. -- Wait with bounded polls and deadlines, never fixed sleeps. -- Multi-PC: pane and workspace calls go through `useMachineApi()`, and storage keys use `paneStorageId(machineId, paneId)`. Never use a mutable global target in async work. -- Component CSS is colocated and uses tokens only: no color literals, no `!important`. `DESIGN.md` must match the token values in `src/styles.css`; no test checks this, so update both together. -- Endless animations use `steps()` or `var(--ease-pulse)` (`src/motion.test.ts` enforces it). -- i18n: the English string is the key and must be a string literal. Every new `t("…")` needs an entry in `src/lib/i18n.ko.ts`, `i18n.ja.ts` and `i18n.zh.ts`; `i18n.test.ts` fails on a missing, unused or untranslated entry. -- Shortcuts are Mod+Shift+key so the pty keeps Ctrl+key. To add one, update `SHORTCUTS`, `KEY_TO_ID` and the switch in `src/lib/shortcuts.ts`. -- Icons come from lucide-react only; brand marks live in `AgentMark.tsx`. When icon files change, bump the `?v=` query in `index.html` and `CACHE_NAME` in `public/sw.js` together. -- UI wording: "New workspace", not "New session". "Session" means the herdr server session or an agent's history. -- There is no linter or formatter. Through `tsconfig.tools.json`, `bun run typecheck` also checks - these critical tooling entrypoints and their imports: `scripts/check.ts`, `scripts/build-site.ts`, - `scripts/ci-lanes.ts`, `scripts/ci-tests.ts`, `scripts/release-notes.ts` and `site/demo/transport.ts`. - Other files in `scripts/` and `site/` are not broadly typechecked, so run what you change there. -- `vite.config.ts` reads `THIRD_PARTY_NOTICES.md` at build time and ships it in `dist/`. - -## Testing - -- `bun:test` only, with no DOM. `src/` tests cover pure logic in `lib/*.test.ts`; component behavior is covered by the Playwright scripts. A `.test.tsx` file is not discovered. -- A test that needs a live herdr is named `*.contract.test.ts`; without that name it runs in the unit suite, except the paths `scripts/ci-tests.ts` lists. Unit tests run with `HERDR_TEST_MODE=unit` and never touch herdr. -- `bun run check fast` is CI's Fast checks and `bun run check full` adds its two lanes, on a herdr of the run's own that reads nothing from the user's config. `bun run check concurrent` runs the same steps as `full`, with the unit tests side by side with both lanes. Lane and `check run` invocations take one of a few PC-wide slots (`CHECK_MAX_RUNS`), one per checkout: a run that finds no slot, or another run in its checkout, exits and names the holders. Reports and browser build provenance follow [Checks](docs/development.md#checks); an old report does not verify edited or restacked code. -- Single file: `HERDR_TEST_MODE=unit bun test ./server/prompt.test.ts`. The `./` is required. -- The unit suite is `bun run test:unit`. Unit tests run in parallel processes; `UNIT_TEST_WORKERS` sets the count (default: min(cores/2, 8) locally, 4 in CI). A bare `bun test` also loads every `*.contract.test.ts`; under `HERDR_TEST_MODE=unit` those fail, since unit mode points `HERDR_SOCKET` at a socket that does not exist. -- `bun run test:ui` does not run `scripts/file-viewer-regression.ts`; CI does. -- Tests run on an isolated herdr session (`herdr-web-ui-test`, or `-1` to `-4` under `bun run test:integration`), never the user's. Stop a leftover one with `herdr --session herdr-web-ui-test server stop`. -- Each `describe` creates and closes its own `herdr-web-ui-test-` workspace with `focus:false`, and mutates only panes it created. -- Every `createServer` in a test gets a temp `stateDir` from `mkdtempSync` and `port: 0`. The default state dir is the user's real devices directory. -- Transcript caches are module-global; tests call `forgetTranscriptState()`. -- NEVER run the real `herdr update` in a test: it replaces the herdr on PATH. -- `scripts/generate-protocol-types.test.ts` rewrites the generated file while it runs; do not edit that file during a test run. -- Two servers cannot attach the same pane: the second gets `attach_held` and waits for the holder. For QA, use a pane the live server does not hold. -- Screenshots and recordings come from the `herdr-web-ui-demo` or a test session, never the user's live session. Playwright scripts serve `dist/`, so build first. +Browser UI for the herdr terminal multiplexer: a React 19 + xterm.js client (`src/`), a Bun +backend (`server/`) that bridges herdr's unix-socket API, and the wire contract both import +(`shared/`). herdr owns every pty, scrollback and agent state; this app is only a bridge. + +These files hold only the traps the code and tests do not show. Commands, layout, checks and +release steps are in [docs/development.md](docs/development.md); contributors follow +[CONTRIBUTING.md](CONTRIBUTING.md). Code conventions (imports, CSS tokens, i18n entries, fixed +sleeps, wording, icons) are enforced by the `conventions.test.ts` files, whose failures say what +to change. Also read [server/AGENTS.md](server/AGENTS.md) and [src/AGENTS.md](src/AGENTS.md). + +## Bridge traps +- Never load node-pty inside Bun: it panics the runtime. PTY work goes through the Node sidecar. +- Never pass `--takeover` to `herdr terminal attach` on the server's own initiative; it cuts off + whoever holds the pane. Only a user's explicit take-over request may. +- One herdr RPC connection per call: herdr closes the socket after each answer. Only + `events.subscribe` stays open, and a second subscribe on it is ignored, so reopen with the full + set. Caching answers is fine. +- `pane.process_info` takes `pane_id`, not `target` (with `target` herdr answers for the focused + pane). +- Keep xterm `scrollback: 0` and never rebuild an attached terminal from `pane.read`: the attach + stream is the truth. Only a mirrored pane (Windows, or no Node for the sidecar) is drawn from + `pane.read`, so never assume a pty exists. +- Nothing the user typed is sent automatically after a disconnect: it waits in a draft until the + user sends or discards it. +- Prompt answers go through `POST /api/pane/prompt/answer`, never guessed digit keys. +- Selecting a pane or tab in the browser never moves herdr's own focus; only an explicit action + that needs it calls `pane.focus`. +- Never edit `shared/herdr-api.generated.ts`; run `bun run generate:types`. + +## Tests and QA +- Tests never touch the user's herdr session or real state: they use the isolated test session, + a `mkdtempSync` `stateDir` and `port: 0` for every `createServer`. Never run the real + `herdr update` in a test. +- Screenshots and recordings come from the demo or a test session, never the user's live one. ## Maintainer workflow - -- PRs only, squash merged; the title is `type(scope): summary` and the merge adds `(#N)`. `main` - requires the "Fast checks", "Integration and browser" and "Native Windows install" (GitHub App - ID `15368`) checks, plus every review thread resolved. -- Contributors branch from `main`; the only exception is an explicitly maintainer-managed stack for genuinely dependent work. See [coordinated work](docs/development.md#coordinated-work) for task ownership, restacking, and authorization rules. -- Release steps are in `docs/development.md#releasing`. NEVER push a `v*` tag by hand: installed updaters act on tags alone. -- Changelog entries end with the PR link, plus `by @login` for an outside contributor: `… ([#208](https://github.com/devswha/herdr-web-ui/pull/208) by @login)`. - - Write the full link (GitHub leaves a bare `#208` unlinked in CHANGELOG.md) and keep `@login` bare (it is what lists the contributor in the GitHub release). - - Link the PR, not the issue. An entry built from several PRs links each one. - - Credit every PR author except @devswha and bots, also when the maintainer pushed fixes to the PR or carried it into a new one; then link both, with `by @login` after the PR that person wrote. - - A PR has no number until it opens, so add the link in a commit after `gh pr create`. At release, fill in missing links and credits from `git log --format=%s ..main` and `gh pr view N --json author`. +- Never push a `v*` tag by hand: installed updaters act on tags alone. +- Required checks: Fast checks, Integration and browser, Native Windows install. Resolve review + threads before merging (branch protection does not enforce it). +- Changelog entries go in `changelog.d/` fragments ([format](changelog.d/README.md)), not + CHANGELOG.md; only a release folds them in. diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6b87059..58424a79c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ([#860](https://github.com/devswha/herdr-web-ui/pull/860)) ### Fixed +- First-device pairing waits for the browser to return its device cookie before restricting + access. A proxy that drops the cookie no longer locks out an otherwise open installation; + Settings reports the failed pairing instead. Already restricted installations stay restricted. + ([#915](https://github.com/devswha/herdr-web-ui/pull/915)) - A message another Claude Code session sends into a pane (its `peer` messages) shows in the chat as a folded **Message from another session: {name}** that opens its own turn, instead of being skipped so the work it started was appended to the answer before it. @@ -84,6 +88,9 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa to the start of the message in the order the files were picked, once a `/command` being typed or an IME composition is finished. It used to go where the caret was, so a path that arrived while you typed landed in the middle of your words and moved the caret. +- A Pi conversation whose session file has grown past 64 MiB reads as a conversation again, where + the chat fell back to the terminal's text (`branch_unreadable`): the reader no longer refuses a + long active branch as a whole, and pages it the way it pages a short one, within the same budgets. - One paste, pick or drop attaches up to 20 files instead of silently keeping the first four, and the message box says how many it left out. **Settings → Chat → Files at once** sets the number for the device, from 1 to 50. @@ -94,6 +101,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa itself offers to interrupt the turn and send it. An ordinary Send and the automatic next-turn delivery never interrupt Codex. ([#783](https://github.com/devswha/herdr-web-ui/pull/783) by @kilhyeonjun) +- On Windows, a pane that runs a launcher which starts OmO as its child is recognized as OmO. herdr + names only the launcher there, so the bridge now also looks for OmO below the pane's shell in the + PC's process table, so its chat and session are found. Only processes below that pane's own + shell count. ([#886](https://github.com/devswha/herdr-web-ui/pull/886) by @willowite) ## [0.4.8] - 2026-10-12 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0326997dc..aa4605871 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,8 @@ adapted from another project, with its license. - Linux (x64, arm64), macOS or Windows x64. Windows uses the screen mirror and needs no terminal addon; Alpine and 32-bit ARM have no addon build. - Bun 1.4 or newer, Node 18 or newer on Linux/macOS (it runs the terminal-attach sidecar), herdr 0.9.0 or newer for - the integration tests. CI runs Bun 1.4.2, Node 22 and herdr 0.9.3. + the integration tests. CI runs Bun 1.4.2, Node 22 and herdr 0.9.3, all pinned in + [.github/actions/setup-ci/action.yml](.github/actions/setup-ci/action.yml). ```bash bun install @@ -76,19 +77,25 @@ running terminals: see [development](docs/development.md#checks). contract test (`server/api.contract.test.ts` for endpoints). A test that needs a live herdr is named `*.contract.test.ts`. - **herdr wire types:** never edit `shared/herdr-api.generated.ts`; run `bun run generate:types`. -- **UI text:** every new `t("…")` string needs an entry in `src/lib/i18n.ko.ts`, `i18n.ja.ts`, - `i18n.zh.ts` and `i18n.tr.ts`, with the same placeholders. `bun run test:unit` fails on a missing, unused or +- **UI text:** every new `t("…")` string needs an entry in `src/lib/i18n.ko.ts`, `i18n.ja.ts` + and `i18n.zh.ts`, with the same placeholders. `bun run test:unit` fails on a missing, unused or untranslated entry. +- **UI languages:** the app keeps English, Korean, Japanese and Simplified Chinese and does not take + new ones. Every language has to translate each later string, so a PR that adds a language is + closed. - **UI:** component CSS uses the tokens in `src/styles.css` (see [DESIGN.md](DESIGN.md)). +- **Color palettes:** the app keeps three (Amber, Charcoal, Catppuccin) and does not take new ones. + Every palette is a light and a dark block that each later token change has to fill in, so a PR + that adds a palette is closed. - **The browser demo:** the [demo](https://herdrweb.dev/demo/) redeploys on every merge to `main`. A feature meant to show there needs an answer in `site/demo/transport.ts`; anything else gets its 404. -- **Changelog:** a change users notice gets a line under `## [Unreleased]` in - [CHANGELOG.md](CHANGELOG.md) (`### Added`, `### Changed` or `### Fixed`). Do not bump versions. - The link to your PR and your name are added by a maintainer at release, so you can leave them out. +- **Changelog:** a change users notice gets a small file in [changelog.d/](changelog.d/README.md) + with its `### Added`, `### Changed` or `### Fixed` lines; do not edit CHANGELOG.md, and do not + bump versions. The link to your PR and your name are added at release, so you can leave them out. ## Releases Maintainers only: see [development](docs/development.md#releasing). App releases are cut by the Release workflow; never push a `vX.Y.Z` tag by hand, since installed updaters act on tags alone. -Remote-PC runtime bundles are released separately with a `remote-vN` tag. +Remote-PC runtime bundles are published with a `remote-vN` tag, and an app release requires the matching one. diff --git a/DESIGN.md b/DESIGN.md index 2b6f758be..27d773946 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -12,7 +12,7 @@ chrome color, amber: focus, the chosen lens, the terminal cursor and the user's buttons). Agent states carry the remaining saturated colors and none of them is amber. The user's chat turns are neutral raised cards, so a long thread never turns into a wall of color. Dark is the default, light follows the same hierarchy, and comfortable or compact density changes scale without -changing information architecture. A dark report look, a neutral charcoal one, Catppuccin, lilac and forest are +changing information architecture. A neutral charcoal look and Catppuccin are opt-in palettes (Settings → Appearance → Colors); amber stays the default and the look before settings load. The sidebar is one grid: a leading cell that says who or what a row is (the coding agent's brand @@ -74,28 +74,21 @@ paper, not UI, and the viewer (`.file-viewer-pdf`) must not tint it for the them ### Opt-in palettes -`settings.palette` (`amber` default, `report`, `charcoal`, `catppuccin`, `lilac`, `forest`) is written as `data-palette`. The -tables above are amber, the base blocks; the five opt-in palettes override them in +`settings.palette` (`amber` default, `charcoal`, `catppuccin`) is written as `data-palette`. The +tables above are amber, the base blocks; the two opt-in palettes override them in `[data-theme][data-palette]` blocks of `src/styles.css`, which hold the complete values. `--bubble-border` is the edge of the chat's user bubble. It is `transparent` where `--bg-elevated` -alone parts the bubble from `--bg`: dark amber, dark report, dark charcoal, dark lilac and dark forest. It is `var(--border)` +alone parts the bubble from `--bg`: dark amber and dark charcoal. It is `var(--border)` where the two surfaces sit close: every light theme (the `[data-theme="light"]` block sets it for all palettes) and dark Catppuccin, whose elevated surface is darker than its canvas. -- **Dark report** is a near-black blue-grey canvas with hairlines: `--bg` `#0a0d12`, panel and - terminal `#0f1319`, text `#b4bdc9` / `#8792a3` / `#e8ecf2`. Primary (the user's action) is white - `#e8ecf2` with `#0a0d12` text; accent is electric blue `#4c9aff`, kept for small marks. Agent - states use meaning colors (working `#f5b544`, input `#ff6b7a`, done `#3ddc97`, idle `#8792a3`). - Terminal cursor `#4c9aff`, selection `#1f3a66`. It has near-square corners (`--radius-sm/md/lg/xl/2xl` - = `2/3/3/4/6px`) and no resting card shadow (`--shadow-card: none`). - **Charcoal** is a neutral Ghostty-style dark: `--bg` `#0f0f0f`, panel and terminal `#171717`, text `#cbc7c0` / `#918c85` / `#f5f2ec`, accent and primary near-white `#e8e4dc` with `#171717` text, muted states (working `#c2a2af`, input `#e0877f`, done `#a7b789`, idle `#9a958e`), terminal cursor `#cbc7c0`, selection `#49443d`. It keeps amber's rounded corners and card shadow. -- In light both use plain paper (`--bg` `#f2f2f0`, panel `#fafaf9`, text `#242424`, primary ink - `#242424` with `#fafaf9` text): report with a blue `#1f5fcc` accent and cursor, charcoal with an - ink accent and cursor `#242424`. + In light it is plain paper (`--bg` `#f2f2f0`, panel `#fafaf9`, text `#242424`, primary ink + `#242424` with `#fafaf9` text) with an ink accent and cursor `#242424`, selection `#dedad3`. - **Catppuccin** follows [catppuccin.com/palette](https://catppuccin.com/palette/), its style guide and its Zed port: Mocha in dark, Latte in light. In both the content (chat canvas `--bg`, terminal) is Base, the chrome (`--bg-panel`: sidebar, the header over it and on a phone, tabs) and elevated surfaces Mantle, @@ -109,31 +102,6 @@ all palettes) and dark Catppuccin, whose elevated surface is darker than its can `#d2d4dc`. Latte's accents are under 4.5:1 on these surfaces, so each keeps its hue and is darkened until it passes AA: Mauve `#712fc6` (primary hover is plain Latte Mauve `#8839ef`), Blue `#1750bf`, Red `#ac0c2f`, Green `#28651b`. It keeps amber's rounded corners. -- **Lilac** is one quiet lavender, flat on every surface (no gradient, no translucency). Light is - the look it was drawn for: a pale lavender canvas `--bg` `#f0eefc` under paler chrome `--bg-panel` - `#f6f5fe` (elevated `#fbfaff`, hover `#e8e5f8`, input `#fdfcff`), indigo ink (text `#2b2d4d` / - `#545779` / `#17193a`), and an indigo accent and primary `#4a42c2` with white text. States are - darkened until their badges pass AA on a hovered row: working `#1f4aa6`, input `#9c2044`, done - `#1f5c39`, idle `#545779`. Terminal `#f8f7fe`, cursor `#4a42c2`, selection `#dcd7f8`. Dark is the - same hue at night: canvas `#16152b`, chrome `#1c1b34` (elevated `#23223f`, hover `#2c2a4f`), text - `#dcdaf4` / `#a5a2cc` / `#f2f1ff`, a pale lilac accent and primary `#b3abff` with `#17163a` text, - states working `#85b8ff`, input `#ff94ad`, done `#92d9ab`, terminal `#18172f`, selection - `#3a3768`. It keeps amber's rounded corners and card shadow, tinted indigo in light. - -- **Forest** is a low-saturation green-grey, flat on every surface, meant to be restful over a long - session (a preference, not a medical claim). In dark the canvas `--bg` `#101a16` sits under - chrome `--bg-panel` `#16231d` (elevated `#1d2d25`, hover `#263b30`, input `#132019`), borders `#2d4437` / `#3d5b49`, text `#d4e5d8` / `#9ab9a3` / `#edf7ef`, a pale green - accent and primary `#8fd3a2` (hover `#a7dfb5`) with `#102016` text. States keep hues of their - own: working `#79c7d4`, input `#ffa294`, done `#91d69a`, idle `#9ab9a3`; danger text `#ffd7d0`, - scrim `rgba(6, 14, 10, 0.6)`. Terminal `#16231d`, cursor `#8fd3a2`, selection `#31513e`. Light is - the same hue by day: canvas `#eaf2ed`, chrome `#f7fbf8` (elevated `#edf5ef`, hover `#e1eee5`, - input `#ffffff`), borders `#c9ddd0` / `#a7c3b2`, ink `#20352a` / `#4f6d5a` / `#15291e`, a green - accent `#2c7648` (darkened until it is AA on a hovered row) and primary `#397f50` (hover - `#2f6e44`) with white text, states working `#166578`, input `#a02b20`, done `#225f32`, idle - `#4f6d5a`, danger text `#9d3022`, scrim `rgba(24, 52, 35, 0.35)` and shadows tinted the same - green. Terminal `#f7fbf8`, cursor `#2c7648`, selection `#cfe5d5`. Tints are these colors at a - low alpha (0.1–0.16). It keeps amber's rounded corners and card shadow, and has syntax colors - of its own (below). ### Terminal theme @@ -152,21 +120,17 @@ CSS tokens verbatim for each resolved theme and palette (`settings.test.ts` chec Code in the chat and in the file viewer is colored by `--syntax-*` tokens. Four follow the palette: `--syntax-comment` is `--text-dim`, `--syntax-function` is `--accent`, `--syntax-inserted` is `--status-done` and `--syntax-deleted` is `--status-blocked`. The other six are literals per block -(each at least 4.5:1 on `--bg-panel` and `--bg-elevated`); light charcoal uses the light report -column. Catppuccin follows Catppuccin's own mapping: keywords are mauve, so `--syntax-function` is +(each at least 4.5:1 on `--bg-panel` and `--bg-elevated`). Catppuccin follows Catppuccin's own mapping: keywords are mauve, so `--syntax-function` is blue there instead of `--accent`, and Latte's colors are darkened to stay readable. | Block | keyword | string | number | type | variable | meta | |-------|---------|--------|--------|------|----------|------| | Dark amber | `#e8875f` | `#a8c17c` | `#d6a0c9` | `#6cb8d6` | `#e0c08f` | `#b49dd6` | | Light amber | `#a33a17` | `#4b6b18` | `#8a3f7a` | `#155a72` | `#7a5418` | `#5d4791` | -| Dark report | `#f78c6c` | `#3ddc97` | `#f5b544` | `#7fd4ff` | `#c9d1dc` | `#b392f0` | -| Light report and charcoal | `#b3261e` | `#22743a` | `#8a5300` | `#0b6e8a` | `#3b3b3b` | `#6f42c1` | +| Light charcoal | `#b3261e` | `#22743a` | `#8a5300` | `#0b6e8a` | `#3b3b3b` | `#6f42c1` | | Dark charcoal | `#d7a08a` | `#a7b789` | `#c2a2af` | `#9fb4c2` | `#dcd8d0` | `#b8a5c9` | | Catppuccin Mocha | `#cba6f7` | `#a6e3a1` | `#fab387` | `#f9e2af` | `#f5e0dc` | `#f5c2e7` | | Catppuccin Latte | `#712fc6` | `#2f7620` | `#b44201` | `#905c13` | `#4c4f69` | `#bc1d91` | -| Dark forest | `#e6a96d` | `#91d69a` | `#d2b18a` | `#79c7d4` | `#d4e5d8` | `#c2a0d8` | -| Light forest | `#9c4f15` | `#225f32` | `#865d13` | `#166578` | `#20352a` | `#68489c` | `--syntax-function` in Catppuccin: Mocha `#89b4fa`, Latte `#0b59f4`. @@ -175,8 +139,8 @@ blue there instead of `--accent`, and Latte's colors are darkened to stay readab - `--notice` colors a note that the view is partial (a file cut short, code too long to color): the size in the header's meta line becomes `256 KB of 1.3 MB`, always beside a warning icon so the color is not the only sign. It is a warning yellow, at least 4.5:1 on - `--bg`, `--bg-panel` and `--bg-elevated`: amber `#e8c55c` / `#7a5a00`, dark report `#f5b544`, light - report and charcoal `#8a5300`, dark charcoal `#d9b26a`, Catppuccin Mocha `#f9e2af` (yellow), Latte + `--bg`, `--bg-panel` and `--bg-elevated`: amber `#e8c55c` / `#7a5a00`, light + charcoal `#8a5300`, dark charcoal `#d9b26a`, Catppuccin Mocha `#f9e2af` (yellow), Latte `#8a5710`. - `--line-number` colors the line numbers beside code: tertiary text, quieter than the code. It is `--text-dim`, except in Catppuccin, whose `--text-dim` (subtext1) is nearly `--text`: Mocha @@ -185,9 +149,7 @@ blue there instead of `--accent`, and Latte's colors are darkened to stay readab ### Rules - Amber is the one chrome color. Accent (selected, focused, informational) and primary (the user's action: Send, primary buttons) are both amber; in light, accent is the darker text-safe ochre and - primary the brighter fill carrying ink text. Agent states never use amber. The report palette - keeps the same split, accent (blue) marks and primary (white, or ink on paper) acts, and there - agent states never use blue. + primary the brighter fill carrying ink text. Agent states never use amber. - Agent state has a written label as well as color. The sidebar's compact glyphs keep their labels in tooltips and screen-reader text; other surfaces draw the words. In the sidebar ready and unknown draw no glyph and keep their label for assistive tech; elsewhere unknown uses dim text @@ -200,21 +162,27 @@ blue there instead of `--accent`, and Latte's colors are darkened to stay readab ### Scale +The values are the ones in `src/styles.css`. Every token but `--fs-input` is `calc( * var(--ui-scale))`: +`--ui-scale` is the interface size factor, which Settings → Appearance → Interface font size writes on +`` (the chosen body size over the density's, `1` until chosen), so the whole scale follows it, while +`--fs-input` stays the floor under which iOS Safari zooms a focused field. + | Level | Token | Comfortable | Compact | Typical use | |-------|-------|-------------|---------|-------------| -| Micro | `--fs-2xs` | `11px` | `10px` | Badges, hints, kbd | -| Meta | `--fs-xs` | `12px` | `11px` | Subtitles, field labels | -| Small | `--fs-sm` | `13px` | `12px` | Controls, row titles | -| Body | `--fs-md` | `14px` | `13px` | Body and dialog copy | -| Reading | `--fs-chat` | `15px` | `14px` | Chat prose, the user's bubble, live narration; the message box with a mouse | -| Large | `--fs-lg` | `16px` | `15px` | Header title, modal title | -| Title | `--fs-xl` | `18px` | `17px` | Markdown h1 | -| Display | `--fs-display` | `22px` | `21px` | The empty chat's greeting | +| Micro | `--fs-2xs` | `calc(11px * var(--ui-scale))` | `calc(10px * var(--ui-scale))` | Badges, hints, kbd | +| Meta | `--fs-xs` | `calc(12px * var(--ui-scale))` | `calc(11px * var(--ui-scale))` | Subtitles, field labels | +| Small | `--fs-sm` | `calc(13px * var(--ui-scale))` | `calc(12px * var(--ui-scale))` | Controls, row titles | +| Body | `--fs-md` | `calc(14px * var(--ui-scale))` | `calc(13px * var(--ui-scale))` | Body and dialog copy | +| Reading | `--fs-chat` | `calc(15px * var(--ui-scale))` | `calc(14px * var(--ui-scale))` | Chat prose, the user's bubble, live narration; the message box with a mouse | +| Large | `--fs-lg` | `calc(16px * var(--ui-scale))` | `calc(15px * var(--ui-scale))` | Header title, modal title | +| Title | `--fs-xl` | `calc(18px * var(--ui-scale))` | `calc(17px * var(--ui-scale))` | Markdown h1 | +| Display | `--fs-display` | `calc(22px * var(--ui-scale))` | `calc(21px * var(--ui-scale))` | The empty chat's greeting | | Input | `--fs-input` | `16px` | `16px` | Mobile-safe text input | -| Pairing | `--fs-pairing` | `32px` | `26px` | The pairing code in Settings → Devices | +| Pairing | `--fs-pairing` | `calc(32px * var(--ui-scale))` | `calc(26px * var(--ui-scale))` | The pairing code in Settings → Devices | | Token | Value | Usage | |-------|-------|-------| +| `--ui-scale` | `1`; Settings → Appearance → Interface font size over the density's body size, written on `` | Multiplies every `--fs-*` token but `--fs-input` | | `--font-ui` | `"Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans KR", "Malgun Gothic", sans-serif` | Chrome and chat prose | | `--font-mono` | `"Symbols Nerd Font Mono", "JetBrains Mono Web", ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, "D2Coding", monospace` | Code, paths, keys, terminal-adjacent metadata | | `--lh-tight` | `1.2` | Titles | @@ -253,10 +221,13 @@ blue there instead of `--accent`, and Latte's colors are darkened to stay readab h2 `--fs-lg`, h3 to h6 `--fs-chat`. Work heads, tool rows, settled narration, a compaction's summary and meta keep their sizes. The chat scales all of them by `--chat-scale` (`--chat-fs-body` is the scaled `--fs-chat`), so the Chat font size setting names the body size and the prose is one step above it. + `--chat-scale` is the chat's size over the interface's: unchosen, the chat follows the interface size; + chosen, it is drawn as itself whatever the interface size. ### Settings - `theme`: `dark`, `light`, or `system`; default `dark`. -- `palette`: `amber`, `report`, `charcoal`, `catppuccin`, `lilac` or `forest`; default `amber`. +- `palette`: `amber`, `charcoal` or `catppuccin`; default `amber`. A removed palette (`report`, + `lilac`, `forest`) loads as `amber`. - `density`: `comfortable` or `compact`; default `comfortable`. - `sidebarRows`: `one` or `two`; default `two`. - Terminal font size is independent: default `13px`, clamped to `10–22px`. @@ -264,30 +235,55 @@ blue there instead of `--accent`, and Latte's colors are darkened to stay readab size in pixels; the grid refits locally and the shared pty resizes once when a finger lifts. Cancellation restores the starting size. - A split pane that would have fewer than 80 columns at that size draws in the largest smaller - whole-pixel size that gives it 80, never below `10px` (Settings → Terminal → Fit 80 columns in - a split pane, on by default). A pane alone in its tab or zoomed, a pane that `10px` cannot bring + whole-pixel size that gives it 80, never below `10px`. A pane alone in its tab or zoomed, a pane that `10px` cannot bring to 80 columns, and a pane this browser only watches or mirrors keep the user's size. The saved size never changes for this; in such a pane a pinch still saves the size it shows, and the pane draws it once it has the room. +- Interface font size (`uiFontSize`): the body size (`--fs-md`) in px, `11–20px`, `null` follows the + density (`14px` comfortable, `13px` compact); it reaches CSS as `--ui-scale` (above). The chat follows + it until it has a size of its own; the terminal keeps its own size. - Terminal and chat font families are comma-separated lists, default empty. They go in front of the - terminal's built-in fonts (after the bundled Symbols Nerd Font Mono, which only draws icons) and of - `--font-ui` in the chat's prose (as `--font-chat`), never in place of them; code in the chat keeps - `--font-mono`. A file viewer's Markdown preview takes the chat font size and family too. + built-in fonts, never in place of them: the terminal's (after the bundled Symbols Nerd Font Mono, + which only draws icons) and `--font-ui` in the chat's prose (as `--font-chat`). The interface keeps + `--font-ui` and code keeps `--font-mono`. A file + viewer's Markdown preview takes the chat font size and family too. At most 200 characters, with `;`, `{`, `}`, `<`, `>`, `\` and control characters stripped and names with spaces quoted. - Composer Enter behavior and folded thinking visibility are preferences, not typography tokens. -- `highlightCode` colors code in the chat and the file viewer by its language (default on; off - shows plain text). -- File viewer: `wrapCode` (wrap long lines instead of scrolling sideways; default off) and - `markdownWidth`: `readable` (the chat lane, `--chat-w`, centered; default) or `full`. - These layout choices stay on this device, like chat width; code highlighting is shared across browsers. - The viewer loads the first 256 KB of a text file through one bounded streaming reader, with abort - on close or navigation and a 5 s fetch deadline. Partial text ends at the last whole line when - one exists; the response's Content-Range supplies the total, and an empty 416 opens empty. - A Markdown file its Preview cannot - be parsed for within 2 s, or whose Preview would draw over 60 000 elements, opens as its source - with a notice; code a worker cannot highlight within 2 s stays plain with one, and code of over - 20 000 lines is one text without line numbers, with one too. +- File viewer: `wrapCode` (wrap long lines instead of scrolling sideways; default off), + `markdownWidth`: `readable` (the chat lane, `--chat-w`, centered; default) or `full`, and + `diffStyle`: `unified` (default) or `split` for a repository diff (a phone draws one column + whatever it says, and the viewer says so). + These layout choices stay on this device, like chat width. + The viewer loads a Markdown file's first 256 KB through one bounded streaming reader, with abort + on close or navigation and a 5 s deadline; any other text that fits 256 KB reads whole under the + same budget, and a larger one reads up to 8 MB and 200 000 lines with a 30 s deadline + (`readBudget`, src/lib/sourcePreview.ts). Partial text ends at the last whole line when one + exists; the response's Content-Range supplies the total, and an empty 416 opens empty. A cut is + told in the header: by size (`8 MB of 9 MB`) when the bytes ended it, by lines when the cap did. + A cut prefix is shown to its last complete line (the reader's line count): the row after its + final newline is not drawn, and a reference to the line just past the cap is explained as unseen, + never marked. A complete file keeps the empty row after its final newline, its last line. + A Markdown file its Preview cannot be parsed for within 2 s, or whose Preview would draw over + 60 000 elements, opens as its source with a notice. A source of up to 2 000 lines is an element + per line, colored on the page or in the app's worker (code a worker cannot highlight within 2 s + stays plain with a notice). A longer source is a window of rows from `@pierre/diffs` + (`VirtualSource`): the DOM holds a few hundred rows whatever the length, every row is numbered, + and the rows take the viewer's tokens inside the package's shadow root (`--bg`, `--text`, + `--line-number`, `--accent-tint` and `--accent` for the marked line, `--font-mono` at `--fs-xs` / + `--lh-base`), never the package's own code background. Up to 256 KB and 5 000 lines it is + colored in the package's worker by a CSS-variable theme mapped to the same `--syntax-*` + tokens as small sources and chat; palette changes reach the tokens inside the shadow root. + Past that the rows are plain with the + "Too long to highlight" notice, since the whole-file highlight measured 55 s and 192 MB for + 8 MB of JavaScript. The wrap setting applies to the rows; a wrap toggle or a width change keeps + the marked line in place when it is in view, else the top line. + File links keep `:line[:column]` and `#Lline[-Lline]` references. A reference opens the source, + marks and centers its first line with `--accent-tint` and an `--accent` gutter number; columns + and range ends do not move the caret. A line past the loaded part or past the end gets an + explicit header notice, never a misleading jump. Markdown reader tabs retain the requested line. + Copy file copies the loaded text; when the clipboard is out of reach it selects the source + `
`, or shows a virtualized source as one readonly text box, selected.
 - All settings share one sanitized `localStorage["herdr-web-ui:settings"]` record.
 
 ## 4. Spacing & Layout
@@ -340,6 +336,7 @@ Comfortable values are `:root`; the final column is the complete compact overrid
 | `--content-w` | `820px` | — | Settings and dialog content |
 | `--pending-max-h` | `min(20dvh, 12rem)` | — | Pending-message stack ceiling |
 | `--chat-w` | `--content-w`, then the pane's lane as one length | — | Chat lane: transcript, composer column, held list. Settings → Chat width: Narrow `--content-w`; Default follows the pane (min 820px, max `60rem` = 960px, 71% of the pane between; the px floor wins where 60rem is under it); Wide `72rem` (1152px); Full `100%` |
+| `--chat-fold-lines` | `12` | — | Lines of prose a long user message shows before it folds behind Show full message (Chat turn); `lib/userMessageFold.ts` folds a message past the same count |
 | `--palette-w` | `640px` | — | Command palette |
 | `--palette-top` | `12vh` | — | Palette top offset |
 | `--context-line` | `calc(var(--fs-lg) * var(--lh-base))` = `24px` | `21.75px`, through the scale | The selected pane's crumb row, min and max height. Scoped to `.context`, not `:root`, so it follows `--fs-lg`/`--lh-base` in either density |
@@ -419,7 +416,7 @@ One set for both themes: the card is island black wherever it shows.
 - The terminal stack contains a positioned terminal surface, then composer or key bar. The xterm
   mount stays alive under the chat lens; changing views never creates a second connection.
 - At `<=480px` the brand name, the offline pill and desktop-only control labels go, and the
-  palette's button gives its room to the title: the palette is the More menu's first item there.
+  palette's button gives its room to the title (a hardware keyboard still opens it).
   Icons and selected context remain.
 
 ## 5. Components
@@ -477,8 +474,7 @@ One set for both themes: the card is island black wherever it shows.
   under its `⋯` with right edges aligned, above it when the screen ends first, and over the drawer.
   Both the button and right-click offer the same native workspace actions: Rename workspace,
   Close workspace/group, New/Open worktree for repository roots, Delete worktree checkout for
-  linked worktrees, and Expand/Collapse for a group. Pane renaming stays in the pane menu;
-  Move pane to… is in header More. The
+  linked worktrees, and Expand/Collapse for a group. Pane renaming stays in the pane menu. The
   danger item takes `--status-blocked`. The popover is as tall as its items, up to the room on
   its side of the button (it scrolls only on a screen too short for it). At `<=640px` it is a `.modal` bottom sheet (`.row-sheet`):
   a grip, the row's name and place, 48px rows and a Cancel button; Tab stays among them. The
@@ -491,24 +487,19 @@ One set for both themes: the card is island black wherever it shows.
   worktree actions, and Expand/Collapse for a group. Dismissal restores the prior focus. The
   explicit `⋯` uses those same actions at its button. An edited name keeps the browser's paste menu;
   touch long press still reorders. Agents and PC headers keep the browser's context menu.
-  Agent rows only select their pane; they have no secondary action button or app context menu.
+  An agent row selects its pane. Its `⋯` (shown as a workspace row's is: on hover, focus,
+  selection, an open menu, and always on touch) opens its menu with one item, Pin agent or Unpin
+  agent; a right-click opens the same menu at the pointer and leaves the focus where it was.
 
 - Close follows herdr's `ui.confirm_close`: a workspace close, or a pane close that takes its
   workspace with it, asks in a confirm first. A busy pane also asks before it stops. After a
   confirmed close, focus lands on the header's workspace-list toggle.
 - Tab and pane menus follow herdr's target scopes. A tab's menu contains New tab, Rename tab
   and Close tab, plus Move tab left/right when a neighbouring position exists and no move is pending.
-  The pane's own context menu carries pane actions; it opens at the pointer
+  The `⋯` that touch and narrow layouts draw beside the selected tab leaves New tab out (the
+  strip's `+` sits beside it) and also carries the open pane's actions, less Split and Zoom: a
+  phone shows one pane at a time. The pane's own context menu carries pane actions; it opens at the pointer
   without changing which pane is focused. The pane header's ellipsis opens the same menu on touch.
-- **Move pane to…** (header More) opens a menu under the header button
-  (`MovePaneMenu`): a dim uppercase MOVE TO micro label with the pane's name under it
-  (`.move-pane-head`), then New tab and the workspace's other tabs, every other workspace under
-  a hairline, and New workspace under another. A pick moves the pane at once (`POST
-  /api/pane/move`); the app stays on a moved pane that was open, also under the new id herdr
-  gives a pane that changes workspace, and waits for the move's answer before it would read a
-  roster without the pane as the pane gone. A refusal reads as the row's or the strip's inline
-  error, herdr's own too ("Move failed: the tab is zoomed; unzoom it in herdr, then move the
-  pane"): a `changed: false` answer never closes the menu as if the pane had moved.
 
 ### Badge (`.badge`)
 - Agent states read **READY**, **RUN**, **INPUT**, **DONE**; unknown reads **—**. **BG** is a pane
@@ -593,21 +584,10 @@ One set for both themes: the card is island black wherever it shows.
   authoritative when the agent roster and pane status differ. The workspace and agent lists
   share the existing combined machine roster and its SSE updates; neither adds a status subscription.
   A disconnected PC's saved workspace and agent rows are dimmed, inert and draw no state until that PC reconnects.
-- Appearance's **Workspace cards** is off by default: the Agents list is flat and every row names
-  its own place. On, in the **Workspaces** order the Agents list puts each workspace's agents on a PC in a card,
-  a workspace with one agent included: a `--hairline` `--border` edge with `--radius-lg`, `--space-1`
-  inside and `--space-2` between cards, and no fill, so a row's hover and selection read as they do
-  outside it. The card opens with a `--space-6` line in `--text-dim` `--fs-xs`: a `--space-4`
-  folder in the leading cell, then the workspace's name, after the PC's when two or more PCs are
-  registered (`machine · workspace`). The line is the card's name, not a control. A row in a card
-  leaves the PC and the workspace out of its dim line (`agentContext` with `grouped`); its tooltip
-  still names them, and the card's list is labelled by its head for screen readers. A row in a card
-  with nothing else to say (no live name, no tab of its own) says its kind there, even the one its
-  mark or title already says, rather than leave the line empty. The head of
-  a disconnected PC's card dims with its rows. **Activity** mixes workspaces, so it draws no cards
-  and every row names its own place. Appearance's **Pin agents** is off by
-  default: rows carry no star and saved pins lead nothing. On, pinned agents, when the list has them, sit in their own
-  section above the cards, and a pinned row is not repeated in its workspace's card.
+- The Agents list is flat and every row names its own place. Pinned agents (pinned from a row's
+  menu) stand first, in the order they were pinned, under a **Pinned** head in `--text-dim`
+  `--fs-xs` aligned with the rows' titles; the rest follow `--space-2` below in the chosen order,
+  with no head. Without pins there is no head. Pins are shared with the other browsers of the server.
 - Appearance's **Sidebar rows** is **Two lines** by default; on **One line** a workspace row is
   its name. Two lines draws the row as the roster did before it was one line, on an agent row's
   height and type: the title of the pane the row opens, and under it in `--text-dim` `--fs-xs` the place,
@@ -620,19 +600,7 @@ One set for both themes: the card is island black wherever it shows.
   workspace, tab and pane order. **Activity** pins a blocked agent on top and orders the rest by
   their most recent state change (herdr's `state_change_seq`, read from the snapshot's `agents`),
   within each PC, so the agent just worked in stays on top while it runs and after it finishes.
-  It changes only the Agents list, which then draws no workspace cards; herdr's order and the
-  workspace rows stay as they are.
-- Appearance's **Quiet opened finishes** is off by default. herdr reports DONE until one of its
-  own clients shows the pane, so an agent opened here would keep its finished dot. On, a DONE
-  opened here (selected while the page is visible) since it finished draws as ready in the
-  Agents list and in its workspace's roll-up. "Opened" is kept per PC in this browser
-  (`herdr-web-ui:seen:`) and dropped with the PC. The first time it is turned on in a
-  browser, everything open on the connected PCs counts as opened, so the lists start quiet; a PC
-  first seen later starts with nothing opened, so its finishes keep their dots until opened.
-  Storage holds herdr's own counters only, so a page reloaded within the poll window after
-  leaving a finish it watched shows that finish's dot again rather than guess. herdr's counter
-  only goes up within a session, so a look recorded above it is from before a herdr restart and
-  is dropped.
+  It changes only the Agents list; herdr's order and the workspace rows stay as they are.
 - Each workspace row holds its folder (top-level rows), a leading glyph, the name (on two lines,
   the title over the place) and the compact state at the right. The leading glyph is the brand mark of the agent in the pane the row opens; a row that opens a
   shell shows the terminal glyph, and a linked worktree without an agent the branch glyph. The
@@ -679,16 +647,12 @@ One set for both themes: the card is island black wherever it shows.
   account in the user's order: provider mark, mono `--fs-2xs` percent of the limit chosen in
   Settings, the plan's week or its session (used, or left when Settings says so), and a 2px bar on a `--border-strong` track
   filled to that percent. From 80% used the percent and bar take `--status-blocked`; amber stays
-  chrome. A limit that runs out before its reset at its pace so far takes `--status-blocked` on
-  the bar alone. A chip whose numbers are stale or missing dims. An account hidden in Settings is
+  chrome. A chip whose numbers are stale or missing dims. An account hidden in Settings is
   left out of the strip and the popover; with every account hidden, neither shows.
 - The button opens a popover above the footer (`--shadow-pop`, `--radius-lg`), as wide as the
   footer and scrolling when it outgrows the sidebar: per account its mark, name and plan pill with
   the email or login right-aligned and ellipsized, then one row per limit (label, reset time, right-aligned percent) over a 4px bar. A problem
-  note is dim, red for an expired sign-in or a failed request. Where the window's start is known, a
-  2px `--text-strong` mark on the bar sits where an even pace would be by now, and a dim `--fs-2xs`
-  line under it gives the rate, with **Runs out in …** in `--status-blocked` when it comes before
-  the reset; once that time has gone by it reads **Runs out before reset** until the reset.
+  note is dim, red for an expired sign-in or a failed request.
 
 ### New workspace dialog
 - Agent select comes from `GET /api/agents`; shell-only is always available. Directory defaults to
@@ -703,7 +667,7 @@ One set for both themes: the card is island black wherever it shows.
 - Submit calls `POST /api/workspace/create`; the server performs `workspace.create` and, when an
   agent was chosen, `agent.start` in its root pane. Pending and partial agent-start failure are
   explicit before the created pane opens.
-- As **New tab** (from a row's `⋯` menu, the header's More menu, the strip's `+` or the palette),
+- As **New tab** (from a row's `⋯` menu, the strip's `+` or the palette),
   the same dialog is titled `New tab · `, shows the workspace's folder as a fact in a
   dashed box (`.new-session-folder`: a worktree's checkout, else the folder of the pane in front)
   instead of asking for one, and its name is the tab's (optional; the placeholder is the number
@@ -714,9 +678,11 @@ One set for both themes: the card is island black wherever it shows.
   Tabs retain their names, status dots, active underline, keyboard navigation, rename field and
   close confirmation. A right-click opens tab actions at the pointer, including Move tab left/right
   when a neighbouring position exists. Desktop tabs have no close or dropdown buttons. Touch and narrow layouts show a `⋯` beside
-  the selected tab for the same menu; Shift+F10 or the menu key opens it from a focused tab.
+  the selected tab for a trimmed menu; Shift+F10 or the menu key opens the full tab menu from a focused tab.
   Pane actions are not tab actions. Up to 768px, where a split tab shows one pane at a time, the
   tab's menu lists the tab's panes first, the open one marked current, over a hairline.
+  Its touch menu omits New tab (the strip's `+` opens it). Only at these narrow widths does it
+  omit Split and Zoom pane; Unzoom pane stays available whenever the tab is zoomed.
 - Mouse dragging previews an accent insertion line, with edge scrolling for an overflowing strip.
   The snapshot's tab order is authoritative: a tab's stable `number` is not its place. A drop calls
   `tab.move` with an insertion boundary before removal, within that workspace and PC. The visible
@@ -727,13 +693,15 @@ One set for both themes: the card is island black wherever it shows.
   the same adjacent moves only while a tab has keyboard focus.
 - `PaneCanvas` draws every pane in the active tab at `layouts[].panes[].rect`, normalized to
   the layout's own area. Panes share thin, square boundaries: `--border-strong` when inactive,
-  `--accent` around the selected pane. The compact header names its pane and provides the
-  ellipsis for touch and keyboard access. A lone or zoomed pane is borderless; its menu button
-  stays at the top-right (visible on hover/focus, always on touch).
+  `--accent` around the selected pane. The compact header names its pane and, on a screen that takes touch
+  (`any-pointer: coarse`), the ellipsis; a mouse opens the same menu by right-click. Keyboard users
+  tab to the pane's ellipsis, which becomes visible on keyboard focus, and press Enter.
+  Shift+F10 in the terminal goes to the terminal program, not the app menu. A lone or zoomed
+  pane is borderless; on touch its actions live in the tab menu rather than a second ellipsis.
 - A left click or keyboard focus selects the actual pane. Right-click opens the clicked pane's
   menu without changing the selected pane: Rename pane, Clear pane name when named, Swap with
-  focused pane when another is selected, Split right, Split down, Zoom/Unzoom pane, the
-  right-click policy, Close pane. It appears at the pointer, clamped to the window. Up/Down
+  focused pane when another is selected, Split right, Split down, Zoom/Unzoom pane, Close
+  pane. It appears at the pointer, clamped to the window. Up/Down
   stop at the ends, hover selects, Enter acts, Escape and an outside click dismiss.
 - Split selects the new pane (`focus:true`), like the TUI. Swap exchanges the right-clicked
   pane with the previously selected pane and retains the latter's focus. A close confirms when
@@ -752,19 +720,20 @@ One set for both themes: the card is island black wherever it shows.
   release flushes the final ratio. A tab/topology change cancels the drag.
 - Every pane keeps its own xterm, attachment, input ownership, lens, chat state and file-path
   context. Only the active pane may take keyboard focus after an asynchronous operation.
-- Switching tabs retains an outgoing terminal only while its explicit submit awaits a receipt;
+- Switching tabs retains an outgoing terminal while its explicit submit awaits a receipt;
   its chat/input UI is unmounted and its grid is not resized. The receipt settles the original
   draft before the pane attachment is released. No input is replayed. A canvas that already released
   its panes while away opens a replacement as view-only until the user returns.
+  An automatic queue also retains its original pane lease until sent, discarded or held.
 - The unlocked app owns one connection per visited PC, shared by its pane mounts. A pane leaving
-  the canvas sends detach and loses its automatic-delivery lease, while the PC connection keeps
+  the canvas without an outstanding submit or automatic queue sends detach and loses its
+  automatic-delivery lease, while the PC connection keeps
   receiving authoritative held/removal receipts. Returning exposes the same pending ID for an
   explicit Send now. Only an actual disconnect, PC removal, sign-out or app teardown suspends
   connection proof; reconnect/reload never resumes pending delivery.
   A newly split pane awaiting its first snapshot does not unmount its siblings. Geometry is
   fitted inside each rendered pane; layout rectangles are never used as PTY dimensions.
-- Plain right-click can be passed to a mouse-reporting terminal when enabled for that pane.
-  Modified clicks, pane frames and non-reporting/read-only terminals still open the menu.
+  A right-click on a pane always opens its menu.
   Native app shortcuts stay Mod+Shift in the browser, so Ctrl+key continues to reach the PTY.
 
 ### Worktree dialog (`.worktree-modal`)
@@ -783,6 +752,60 @@ One set for both themes: the card is island black wherever it shows.
 - The browser demo answers the same list/create/open/remove worktree APIs with fictional
   checkout records, so its branch rows and dialogs work without a filesystem or live herdr.
 
+### Changes list and diff viewer (`.repo-changes`, `.repo-diff*`)
+- Files has two faces, **Browse** | **Changes** (`.segmented` in its header). **Changes** lists
+  what a comparison changed in the repository the pane sits in: the working tree against the
+  index, the index against HEAD, or two commits named by hand (**From commit**, **To commit**,
+  **Compare**); a Refresh button re-reads it, nothing polls. Under the controls, the branch
+  (`Detached HEAD`, `No commits yet`) beside a `GitBranch` glyph and the repository's root, mono,
+  cut from its start. The files sit under the folders they are in (lib/changeTree.ts): a
+  `.repo-change-folder` button row (caret, `--accent` folder glyph, name, and the count of files
+  below it in `.repo-change-folder-count`) stands where its first file was listed, a folder holding
+  nothing but one folder joins it into one row (`src/components/diffs`), every folder starts open,
+  a tap folds it (kept across Refresh and scope changes), and an icon button beside Refresh folds
+  or opens them all. Each level moves its rows in by `--depth` steps of `--repo-change-indent`, in
+  nested lists. One `.repo-change` row per file, like the folder browser's rows (only file rows
+  carry that class, and the folder prefix of a path stays in `.repo-change-path` as visually hidden
+  text, so a screen reader reads the whole path in listing order): git's own
+  letter in a `.repo-change-mark` chip (M, A, D, R, C, T, U, ?), the path in mono with a rename's
+  old path dimmed before an arrow, and `+added −removed` in `--syntax-inserted` /
+  `--syntax-deleted`, or **Binary**. The chip is tinted as the diff's rows are: added and
+  untracked on `--status-done-tint`, deleted and conflicts on `--status-blocked-tint`, modified on
+  `--accent-tint`; an untracked file's chip has the dashed edge of an unknown state. The status
+  word is kept for assistive tech (`.visually-hidden`), so the letter is never the only name.
+  Empty and refused comparisons say so in the list's own words (`No uncommitted changes`,
+  `Nothing staged`, `These commits are the same`, a cut list with its counts, a folder outside
+  git, a commit that does not exist, a bridge too old for the routes), never in git's.
+- A row opens `.repo-diff-viewer`, a modal above the list with a history entry of its own
+  (lib/useRepoDiffViewer.ts, as a file preview has): Back, Escape, Close and the scrim each close
+  it alone and return the focus to its row; a reload restores it. Its header is the file viewer's:
+  the name cut inside its stem, a mono meta line with the status in words (`.repo-diff-status`,
+  tinted like the chip), the counts, a `--notice` warning with an icon when the rows are left
+  plain or forced to one column, the comparison (`Working tree`, `Staged`, `abc1234..def5678`),
+  and the folder giving way first. Its actions: **Unified** | **Split** (`.segmented`, the
+  `diffStyle` setting; Split is disabled at `<=640px` with the reason as its tooltip and the note
+  in the meta line), **Fold context** (an `aria-pressed` icon button: one unchanged line around
+  each change instead of the three git printed, the rest counted in the separators), **Open
+  file** (the file viewer, on the PC that holds the repository; not for a deleted file) and
+  **Copy patch** (git's own text; without a clipboard the patch becomes a selected text box, as
+  the file viewer's Copy does). On a phone the actions take the second row under Close.
+- The rows are `@pierre/diffs`' `FileDiff` inside its `Virtualizer`: a window of a few hundred
+  rows whatever the patch's length, drawn in the shadow root with the same tokens as a
+  virtualized source (the `--diffs-*` hooks of lib/sourceTheme.ts): text `--text`, background
+  `--bg`, numbers `--line-number`, and `--syntax-inserted` / `--syntax-deleted` as the package's
+  base colors: its gutter bars, signs and word emphasis take them verbatim, and it mixes the added
+  and removed rows' backgrounds from them and `--bg` (no tint token is handed to it, since it
+  would be mixed again); separators on `--bg-elevated` with the package's own `N unmodified
+  lines`. Old and new line numbers sit in the gutters, outside a
+  selection and a copy; a blank row keeps its newline. Colors come from the package's worker
+  within the source bounds (256 KB, 5 000 lines), else the rows stay plain and the header says
+  `Too long to highlight`. Between hunks nothing expands: the routes serve the patch alone, not
+  the files around it; **Open file** is the way to the whole file.
+- What has no rows says so in a `.repo-diff-note`: a binary file, a change past the server's
+  patch cap (with the cap in bytes), a merge conflict, a path that is not a regular file, a pure
+  rename or mode change (`No lines changed.`), a file that no longer differs, and every refusal of
+  the routes in the app's words.
+
 ### Header context and connection
 - A selected pane shows agent mark + title (`--fs-md`), then on the same line the crumb
   PC › workspace › folder (`--fs-xs`, `--text-dim`). The folder shows as its last name, and only
@@ -791,9 +814,9 @@ One set for both themes: the card is island black wherever it shows.
   whole title it is not drawn, and the title has the row. The full path is in the context's
   tooltip and at the top of the More menu. With no selection, the brand fills the context slot.
 - The More menu (`.header-more-button`, the `RowMenu` popover, a bottom sheet at `<=640px`) opens
-  with the location on two lines, "PC › workspace" then the full path in mono, and holds **New
-  tab** (the selected pane's workspace), **Browse files** and **Alerts**, each under the condition
-  its own button had; at `<=480px` **Command palette** is its first item. The Alerts item says
+  with the location on two lines, "PC › workspace" then the full path in mono, and holds
+  **Browse files** and **Alerts**, each under the condition its own button had. New tab stays
+  on the tab strip's `+`; Command palette is not in More. The Alerts item says
   this device's state in words after its label ("On in the app", "On in this tab", "On, pushed to
   this device", "Off on this device") and switches it: it is a `menuitemcheckbox` with
   `aria-checked` in the popover and a button with `aria-pressed` in the sheet. While alerts are off on this device the
@@ -806,6 +829,26 @@ One set for both themes: the card is island black wherever it shows.
   document as a `role="status"` a screen reader can read (`.conn-live`; never `display: none`).
 
 ### Chat turn (`.chat-turn`)
+- Native conversation rows use LegendList 3.3.5, with the chat view as the single
+  scroll owner. The centered lane, exchange spacing and prompt portal stay separate from
+  row lifetime. The list anchors prepends by a browser-local slot, never by a timestamp or
+  mutable message text; machine, pane and history replacement reset that slot scope.
+  Selected ranges and focused rows remain mounted outside the visible window. Disclosure
+  choices and completed whole-output reads belong to logical rows and survive eviction.
+  Spare list containers may remain offscreen after a selection ends; this is not an
+  unlimited transcript DOM or a promise of bounded memory for all loaded source data.
+  The web ESM patch orders pooled DOM slots before paint, uses state-preserving
+  `moveBefore` where available, and clears stale prepend locks on explicit navigation.
+  These changes preserve native drag/copy order and prevent a later layout from undoing
+  Find navigation; the React Native and keyboard patches from t3code are not included.
+- The chat-local **Find in messages** button and Mod+F search supplied message source,
+  including folded work, rather than the mounted DOM window. The existing Mod+Shift+F
+  terminal action remains unchanged. The panel takes its own space above the transcript,
+  never covering a match, with existing search controls and `--accent-tint` match emphasis. A selectable
+  source excerpt makes a match in folded content readable without expanding every tool.
+  **Search earlier messages** reads older pages sequentially until a new match or the
+  beginning; Cancel stops further pages. The scope label distinguishes loaded source from
+  history that has not been read. Escape closes the panel and restores its button's focus.
 - The chat lens is a centered `--chat-w` transcript over the still-attached terminal surface.
   At the Default chat width the lane follows the pane: min 820px, max 60rem (960px at a 16px
   root), 71% of the pane (`.terminal-stack`) between. `PaneTerminal` measures the pane and writes
@@ -823,6 +866,16 @@ One set for both themes: the card is island black wherever it shows.
 - The register is Codex / gajae-code-app: a quiet document. User turns are right-aligned neutral
   cards (`--bg-elevated`, a `--bubble-border` edge, `--radius-lg` on all four corners, ≤80% wide,
   no avatar or name). Assistant turns have no header: the answer is plain prose.
+- A long user message (`lib/userMessageFold.ts`: more than `--chat-fold-lines` lines, or more
+  characters than about that many lines of the widest bubble hold, a link or image counted by its
+  label) is capped at `--chat-fold-lines` lines of its prose. Only such a message is measured, and
+  only where the cap cuts it does it fold: the last line fades into the card through an alpha mask
+  one prose line tall, over a **Show full message** text button on the card's right edge
+  (`--text-dim`, `--fs-xs`, `--bg-hover` and `--text-strong` under the pointer; a `--touch-target`
+  row where the primary pointer is coarse). Open, it reads **Show less**, and folding brings the
+  card's top back into view as a code block's fold does. The choice stays with the row while it
+  leaves and re-enters the list, and Find landing on the turn opens a cut message, so a match is
+  never behind the fold.
 - Space separates exchanges, not a rule: the transcript's gap is `--space-3`, so an answer sits
   close under its prompt, and a user turn that follows an assistant turn takes `--space-5` more.
   The one rule of an exchange is the hairline under its folded work block.
@@ -841,7 +894,8 @@ One set for both themes: the card is island black wherever it shows.
   **Open raw file** (Code glyph) opens the original bytes; Download still saves those bytes.
   The reader page and dialog share **Show source** (Braces glyph, pressed while the source shows, so it
   never looks like Open raw file), highlighted source with CSS-counter line
-  numbers, and **Copy file** for complete text (selecting the source if clipboard copy fails).
+  numbers (past 2 000 lines, the virtualizer's numbered rows; see File viewer under Typography),
+  and **Copy file** for complete text (selecting the source if clipboard copy fails).
   There is no separate Raw action. HTML blocks in Markdown files are parsed in the preview worker
   with HTML enabled; local images still pass through the file endpoint's image-size check.
   The page has an h1 and no Close or new-tab action; the dialog keeps its focus trap and sheet swipe.
@@ -999,17 +1053,34 @@ One set for both themes: the card is island black wherever it shows.
   and the composer column equals the transcript column (`--chat-w`, same `--space-4` gutter,
   `--space-3` at `480px` and below).
   On a phone (`<=768px`), while the card follows the transcript directly (no held message, pending
-  row or prompt card between them) and no greeting stands over it, the card floats over the
+  row, prompt card or notice between them) and no greeting stands over it, the card floats over the
   transcript's end as an iPhone's input bar does: the composer rises `--composer-over`
   (`--space-8`) into the transcript, whose last lines fade into `--bg` and blur behind the card on
   `--card-glass` and `--glass-blur`.
   The input box is a card: `--bg-elevated`, hairline border, `--radius-2xl`, `--shadow-card`
   (the only other card on this surface is the prompt card docked over it, while an agent asks);
-  focus turns its border `--accent` (no inner outline). Above it the completion popover and the
-  background-task list; inside, the image strip is its own row at the top, then the auto-growing
+  focus turns its border `--accent` (no inner outline). Above it the notices (below), the
+  completion popover and the background-task list; inside, the image strip is its own row at the top, then the auto-growing
   message box as a row of its own at the card's full width, then ONE row of controls under it:
   on the left the add button (lucide `Plus`, named "Attach files"), the mic when voice input
   shows it (see Voice input), and the background-task chip; on the right the status content and ONE round Send/Stop button.
+  The message's text starts `--space-4` in (`--space-3` on a phone, `480px` and below).
+- The strip (`.composer-strip`) tucked under the card, as t3code's composer context strip: the
+  card's own `--bg-elevated` with a `--border` hairline, inset on each side by the card's radius
+  (`--radius-2xl`, never under `--space-5`) so it stays under the card's straight bottom edge,
+  its top `--space-4` under the card, where the card's hairline and `--shadow-card` part the two,
+  and only its bottom corners rounded (`--radius-xl`); `--space-5` top padding (so `--space-1`
+  shows under the card), `--space-3` at the sides, `--space-1` under. It names the pane's
+  working folder at its start (lucide `Folder`, the folder's last name, the whole path in its
+  `title`) and the branch at its end (lucide `GitBranch`), each a glyph the text's size and a
+  name in `--text-dim` at `--fs-xs`, `--chip-h` tall; a name that does not fit is cut short.
+  A linked worktree's folder takes lucide `FolderGit2` and the word Worktree after its name, in
+  a `--border` hairline box with `--radius-sm` corners.
+  Nothing in it is pressed. `composerStrip` (`src/lib/composerStrip.ts`) decides what it draws from
+  the composer's optional `cwd` and `branch`: a blank one is absent, and with neither there is no
+  strip at all. A phone (`640px` and below, or a coarse pointer) draws no strip: the screen is the
+  conversation's. The card stands over it (`z-index: 1`, so its popovers go with it and still rise
+  over the transcript).
 - Empty chat (`.composer-greeting`): one line on the composer's column, directly over it,
   `What should  do in ?` in `--text-strong`, `--fs-display`, `--fw-semibold`, `--tracking-display`, centred,
   and under it `PC · full path` in `--text-dim`, `--fs-sm`. Both wrap anywhere. While dictation's
@@ -1045,7 +1116,8 @@ One set for both themes: the card is island black wherever it shows.
   `--fw-medium`; the level follows a middle dot in `--text-dim`, as the agent's own words with
   only the first letter drawn as a capital (`::first-letter`, the text is not rewritten); a pane
   that records no level draws no dot and no dash, only the name. On the
-  fill the ring's track is `--border-strong`; a ring left bare on the card keeps `--border`. It is display only: a `span` with no role, no
+  fill the ring's track is `--border-strong`; a ring left bare on the card keeps `--border`.
+  No part carries a chevron: herdr owns the agent, so none of them opens a menu. It is display only: a `span` with no role, no
   focus, no hover or pressed state, no pointer cursor and no chevron; the ring inside it is the
   one thing to press on desktop; mobile keeps the ring inert. The `title` of the model is the id as received, and the level's is its
   sentence; behind a name the id is also repeated as `.visually-hidden` text, since a touch
@@ -1065,11 +1137,15 @@ One set for both themes: the card is island black wherever it shows.
   suffix is dropped.
 - What does not fit the row gives way in this order: the task chip's words (icon and count below
   a `640px` card), then the reasoning level and model name according to the measured row. A long
-  identifier can shorten inside the pill; the context ring remains visible. The row is measured
+  identifier can shorten inside the pill; the context ring remains visible: a name that would
+  be cut to its floor (the `3ch` the box keeps) steps out whole instead, with its mark and its
+  pill (`composerLabelDraw`, `src/lib/composerLabelFit.ts`, measured again with the level
+  stepped out), so the ring stands alone and whole on a `320px` phone rather than being cut by
+  the pill's edge. The row is measured
   again after fonts arrive and the context label opens or closes. The input card keeps one row
   of controls on desktop and phones.
 - Every agent has one primary control: Stop while working with an empty draft, Send with text,
-  and Send at rest. Uploads and in-flight sends disable Send without clearing its text. There
+  and Send at rest. Uploads and in-flight sends disable Send without clearing its text. An in-flight Send keeps its accent circle at full opacity and replaces the arrow with a small progress ring, rotating in eight steps using `--dur-spin`; reduced motion keeps the ring static. Its accessible name is `Sending…` and `aria-busy` stays true until the receipt settles, without a live region. The button keeps the same size and position, even if its draft is edited to empty while sending. There
   is no separate Queue button or send menu. The placeholder is `Message …`; message text
   uses `--fs-chat` scaled by `--chat-scale` on `--lh-code` with a fine pointer. Otherwise it
   uses that scaled size or `--fs-input`, whichever is larger, keeping the 16px floor that avoids
@@ -1098,7 +1174,13 @@ One set for both themes: the card is island black wherever it shows.
   of its own under it, without its leading dot, and wraps there; its `title` repeats it. Only
   the sentence takes a line: the mark, the model, the level and the ring stay one row over it
   (`.composer-status-meta`), where the label gives way exactly as it does with no sentence. The
-  status content is then left-aligned, beside the add button. The primary control and add are disabled. With a draft, the reconnecting sentence is said instead of
+  status content is then left-aligned, beside the add button. In a narrow card (`data-compact`,
+  below `640px`) add, the icons and the round button at touch size leave it too little of the
+  row for a word, so while a sentence shows the status content takes a row of its own over them,
+  the card's width (`.composer-surface[data-compact]:has(> .composer-status[data-hint])`): the
+  sentence is read whole beside the model pill or under it, from where the message's text
+  starts, add and the round button keep their row, and the card grows by that row while the
+  sentence shows. The primary control and add are disabled. With a draft, the reconnecting sentence is said instead of
   `Uploading file…`, never both: the attachment's own tile says it is uploading.
 - The resize grip is a short bar on the card's top edge. On a fine pointer (`(hover: hover) and
   (pointer: fine) and (not (any-pointer: coarse))`) the bar is drawn while the card is hovered,
@@ -1106,7 +1188,24 @@ One set for both themes: the card is island black wherever it shows.
   pointer it is always drawn. Its hit area lies above the card (`--composer-grip-h`), and the
   composer keeps that strip free after the held-message list and after a prompt card.
 - The stack over the input card, on its column, is written once (PaneTerminal): held messages,
-  then the prompt card (see Prompt card), then the input card.
+  then the prompt card (see Prompt card), then the input card. The notices are the input card's
+  own: they stand inside the composer, over the card and its quick replies, so they move with it
+  (a lifted greeting, a phone's keyboard) and the prompt card keeps its place directly over them.
+- Notices (`.composer-notices`): under the chat lens, while the input card shows, the states the
+  terminal lens floats as top-right pills (another app holds the pane, a failed output stream, the
+  input held for the terminal, a copy note) stand over the card instead, in the held messages'
+  anatomy: one `--border` hairline above the group, then a row per notice (`role="status"`), no
+  tint and no box, every line of text `--space-3` in. A row is its lucide mark and its sentence in
+  `--text-dim` at `--fs-xs` (`--status-working` for the two warnings: another app holds the pane,
+  the stream failed), its own way on at its end (`Open here`, `Reconnect`, the held input's `Send`
+  and quiet `Discard`, in the held rows' buttons) and an X named `Dismiss notice`. Top to bottom
+  the rows are the plain notice, then the urgent ones, then the activity (the held input) nearest
+  the card, as t3code's composer banner stack orders them (`src/lib/composerNotices.ts`). A
+  dismissal lasts while the notice stands and says the same thing; it is forgotten once the
+  notice goes or changes its words, so the next one shows again, and another pane's dismissals
+  never carry over. The group keeps the grip's hit strip free under it. With no input card (view
+  only, ended, a secret prompt) the pills stay where the terminal lens draws them, and a notice
+  keeps a phone's card docked: it never floats over the transcript.
 - Held messages (`.composer-queue`) are quiet rows on the input card's column, above it: the
   card's width and gutter at every window width, no tint and no box. One `--border` hairline
   above the group, then one caption line in `--text-dim` at `--fs-xs` (a clock and the sentence
@@ -1131,12 +1230,13 @@ One set for both themes: the card is island black wherever it shows.
   still `Discard`.
 - `/` completions come from `GET /api/pane/commands` and group built-in, user and project commands;
   `@` completions query `GET /api/pane/files`. Arrow keys navigate, Enter/Tab accepts, Escape closes.
-- Paste, picker or drag/drop attaches up to **Files at once** files per action (Settings → Chat, 20 unless the
-  device chose 1 to 50); the message box names how many it left out. An image is stored as a paste, any other
-  file under its own sanitised name. Each uploads through `POST /api/pane/image`, three at a time
+- Paste, picker or drag/drop attaches up to 100 files per action; the message box names how many it left out.
+  An image is stored as a paste, any other file under its own sanitised name. Each uploads through `POST /api/pane/image`, three at a time
   across all picks, and puts a removable editable `@path` mention at the start of the message (after
   a leading `/command`), in the order the files were picked. A path waits while the box holds only a
-  `/command` still being typed or an IME composition. The caret and selection move only with the
+  `/command` still being typed or an IME composition, and a path that does not fit whole under the
+  length cap waits on a "No room" tile, with Send off and the status line saying how many characters
+  to remove; within 200 characters of the cap the status line counts what is left. The caret and selection move only with the
   text they sit in, so typing while files upload is not disturbed.
 - While a phone's keyboard is up, a tap on the transcript or a drag down it (`32px`) puts the
   keyboard away. Each only blurs the field, so the draft stays. The prompt card, which can stand
@@ -1146,10 +1246,7 @@ One set for both themes: the card is island black wherever it shows.
 - On a touch screen, picking a pane (drawer, palette, notification) or switching its lens never
   raises the keyboard: the user reads first, and a tap on the message box or the grid raises it.
   A desktop's picked pane takes typing at once.
-- Enter sends and Shift+Enter breaks by default; with **Enter sends** off, Mod+Enter sends. IME Enter
-  is ignored. Stop sends Escape. While the agent works and a draft shows Send in Stop's place,
-  Escape in the box is Stop; an open completion menu takes the first Escape. Working Send
-  schedules a pending message; its Send now action submits that same message now.
+- Enter sends and Shift+Enter breaks by default; with **Enter sends** off, Mod+Enter sends. On touch screens the same device setting is shown inverted as **Return inserts a new line**, with Send and hardware Mod+Enter explained below it. IME Enter is ignored. Stop sends Escape. While the agent works and a draft shows Send in Stop's place, Escape in the box is Stop; an open completion menu takes the first Escape. Working Send schedules a pending message; its Send now action submits that same message now.
 
 ### Voice input
 - A mic button sits beside the add button in the composer and beside Send in the terminal input line; it
@@ -1162,6 +1259,9 @@ One set for both themes: the card is island black wherever it shows.
   Amber only; `--danger` stays for errors.
 
 ### Command palette
+- The command palette and its plugin actions are not available in the phone touch UI
+  (`<=480px`): neither the header nor Files offers a palette button. Use a wider screen;
+  a connected keyboard can still open it with `Mod+Shift+K` (or the configured binding).
 - `Mod+Shift+K` opens a top-offset `--palette-w` dialog searching panes across all connected PCs
   and the selected PC's actions. PC names participate in search. Recent panes
   lead an empty query; arrows in the search field cycle through visible results, Enter activates
@@ -1224,11 +1324,11 @@ One set for both themes: the card is island black wherever it shows.
   control, the X, Escape and the scrim take the same entries off. Beside the list, turning pages
   replaces the one entry. A reload steps out of the entries it finds; Forward reopens the page.
 - Pages, in order: **Appearance** (theme, colors, density, language, sidebar rows), **Chat**
-  (panes open in, show thinking, chat width, chat font size and family, highlight code; then
-  **Composer**: optional plain PageUp/PageDown chat scrolling (off by default), Enter sends, files at once, suggestion chip; then **Quick replies**; then **File viewer**: wrap long
-  lines, Markdown width), **Terminal** (font size, 80 columns in a split, font family, wheel
+  (panes open in, show thinking, chat width, chat font size and family; then
+  **Composer**: Enter sends; then **Quick replies**; then **File viewer**: wrap long
+  lines, Markdown width, Diff layout), **Terminal** (font size, font family, wheel
   speed, input mode, Key bar), **Alerts**, **Voice input**, **Subscription usage**,
-  **Shortcuts** (the platform-resolved global bindings, then **Paging** with a 0–100 line overlap stepper; one line by default, zero for a full viewport), **Phone & devices** (the phone address,
+  **Shortcuts** (the platform-resolved global bindings), **Phone & devices** (the phone address,
   Keep screen on, Install; then Portal, where the server offers it; then paired devices),
   **Remote PCs**, **About** (Updates, herdr, the repository links). A button that points at
   Updates opens the dialog on About.
@@ -1294,22 +1394,26 @@ One set for both themes: the card is island black wherever it shows.
 - Automatic refresh uses `jump:false` only after an explicit search response includes both
   `matches` and `scroll`. Older bridges can ignore unknown request fields, so they receive no
   automatic search POST. Their explicit count and previous/next navigation remain unchanged.
-  Read-only views also send no automatic search POST and cannot drag the history scrollbar.
-- The separate history scrollbar sits in a reserved gutter alongside the grid and is hidden
-  when herdr reports no scrollback. Dragging the thumb, clicking the track or its focused arrows,
-  Page Up/Down and Home/End call `pane.scroll`; this moves the shared viewport for every client,
-  including the TUI. Pending pointer moves are coalesced, requests retain their owning PC/pane,
-  and a failed request displays an error instead of inventing a local history position.
+  Read-only views also send no automatic search POST and cannot page native history.
+- The terminal draws no history scrollbar and reserves no gutter for one: the grid takes the
+  mount's full width. Wheel, touch drag, page keys and Find move native history; paging calls
+  `pane.scroll`, which moves the shared viewport for every client, including the TUI. Requests
+  retain their owning PC/pane, and a failed request displays an error instead of inventing a
+  local history position.
 - Mod+Shift+PageUp/PageDown pages the selected pane only, preserving chat composer focus and
-  selection. Chat moves instantly by its viewport minus the configured prose-line overlap;
-  Terminal and focused scrollbar PageUp/PageDown use native viewport rows minus that overlap.
-  A large overlap still advances one pixel or one terminal row. Paging is blocked by modals;
+  selection. Chat moves instantly by its viewport minus one prose line;
+  Terminal uses native viewport rows minus one. A page still
+  advances at least one pixel or one terminal row. Plain PageUp/PageDown always scroll the chat
+  transcript. Paging is blocked by modals;
   repeated terminal pages accumulate against the pending offset until herdr acknowledges it.
-- Explicit Find cancels unsent scrollbar offsets and waits for a native scroll already sent;
-  the scrollbar is disabled until that search ends, so an older drag cannot overwrite its result.
+- Explicit Find cancels unsent page offsets and waits for a native scroll already sent;
+  paging is disabled until that search ends, so an older page cannot overwrite its result.
 - xterm has `scrollback: 0`; wheel/touch gestures reach herdr's alternate-screen scrollback. The
-  mount clips its own gutter and hides the unused xterm scrollbar.
+  mount hides the unused xterm scrollbar.
 - Terminal banners stack top-right for ended, reconnecting, observe and held-draft review states.
+  Under the chat lens, while the input card shows, the ones it shares with the composer (another
+  app holds the pane, a failed output stream, the held draft, a copy note) stand over the card
+  instead (Composer: Notices).
 - The mobile key bar starts with Esc, Tab and `^C`, then sticky Ctrl/Alt/Shift, Enter and arrows;
   it never steals typing focus. Modifiers remain highlighted until toggled off,
   leaving the pane/lens or disconnecting; each exposes `aria-pressed`.
@@ -1346,6 +1450,8 @@ One set for both themes: the card is island black wherever it shows.
 
 ### Timing
 
+The values are the ones in `src/styles.css`.
+
 | Type | Token | Value | Usage |
 |------|-------|-------|-------|
 | Micro | `--dur-fast` | `120ms` | Hover, active, toggle and control state |
@@ -1423,9 +1529,10 @@ One set for both themes: the card is island black wherever it shows.
   Paging scrolls herdr history when present; an explicit valid zero-history result instead sends
   one plain PageUp/PageDown through the existing live input lease to the running application.
   Application-owned paging has its own page length; no page input is queued across disconnects.
-  The opt-in plain chat page keys preserve composer draft, selection and focus, excluding modified
+  The plain chat page keys preserve composer draft, selection and focus, excluding modified
   keys, composition, other editable controls and modal input. They do not capture terminal keys.
-- On touch screens app actions stay in buttons and menus, including the palette in More on phones.
+- On touch screens app actions stay in buttons and menus. The command palette and its plugin
+  actions are not available in the phone touch UI; a wider screen or keyboard is required.
   The key bar sends terminal input only; it is not an app-action toolbar. Hardware keyboards use
   the same global bindings on phones and desktops.
 - `document.title` is ` · herdr` while selected, otherwise `herdr web ui`.
diff --git a/README.md b/README.md
index b11e0168c..ede96cbcb 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
 

- English · 简体中文 · 日本語 · 한국어 · Türkçe + English · 简体中文 · 日本語 · 한국어

diff --git a/README.tr.md b/README.tr.md deleted file mode 100644 index ea3959e23..000000000 --- a/README.tr.md +++ /dev/null @@ -1,190 +0,0 @@ -# herdr web ui - -

- herdr web ui -

- -

- English · 简体中文 · 日本語 · 한국어 · Türkçe -

- -

- web sitesi · - kurulum · - demoyu dene · - hızlı başlangıç · - sss · - dokümanlar -

- -

- MIT lisansı - GitHub yıldızları - Son sürüm - herdr 0.9.0+ - Yüklenebilir PWA -

- ---- - -https://github.com/user-attachments/assets/d854dbb6-64bd-4eba-81c7-fbd3f525726b - -

Tek bir Claude Code oturumu: önce herdr'ın terminalinde, sonra tarayıcının Terminal ve Sohbet görünümlerinde, en sonda telefonda — yanında yeni bir worktree açılır · canlı kayıt, kesmesiz

- -**Claude Code ve Codex, telefonunuzdan.** - -[herdr](https://github.com/herdrdev/herdr) için tarayıcı ve telefon istemcisi. Bilgisayarınızda çalışan aynı ajan oturumlarını okuyun ve yanıtlayın — masaüstünde veya telefonda, sohbet olarak; gerektiğinde terminaliyle birlikte. - - - - - - - - - - - - - - -
- Canlı kayıt: tarayıcıda Sohbet görünümünde Claude Code'un yanıtı; Terminal'e tıklayınca aynı panel, Claude Code'un kendi terminalindeki gibi görünür ve düzenlemesi src/server.test.ts'e test satırını ekler. Telefonda tuş çubuğundaki ↑, bun test komutunu geri getirir; yanındaki Enter onu yeniden çalıştırır: 5 geçti, 0 kaldı. -
Canlı terminale geçin -
Tek tıkla sohbet, panelin gerçek terminaline dönüşür; telefonda tuş çubuğundaki ↑ test komutunu geri getirir, yanındaki Enter onu yeniden çalıştırır. -
- Canlı kayıt: tarayıcıda herdr düzeni — kenar çubuğunda dört çalışma alanı, her satır ajanının durumuyla biter; sekmeler ve bölünmüş paneller görünür. Telefonda ☰ aynı çalışma alanlarını açar, bir dokunuş Claude'un sohbetini getirir. -
herdr düzeniniz, tarayıcıda -
Her çalışma alanı, sekme ve bölünmüş panel, her ajanın durumuyla birlikte: bir sekmeye tıklayın, bölünmenin bir panelini seçin ya da çalışma alanlarına telefondan geçin. -
- Canlı kayıt: Claude çalışırken telefonda başka bir çalışma alanının terminali açıktır. Claude hız limitini sorunca telefona bir uyarı iner; dokunuş soruyu kart olarak açar. -
Bir ajanın size ihtiyacı olduğunu bilin -
Başka bir çalışma alanındayken Claude bir şey sorduğunda uyarı iner; tek dokunuş sorusunu açar. -
- Canlı kayıt: telefonda boş sohbete ekran görüntüsü eklenir ve 'Bunu düzelt, bir testle birlikte.' yazılıp gönderilir; mesaj telefonda ve masaüstünde görünür. -
Telefondan ekran görüntüsü gönderin -
+ düğmesi görüntüyü panelin klasörüne yükler ve yolunu ekler; Claude görüntüyü okur. -
- Canlı kayıt: terminaldeki yanıttaki yola tıklayınca grafik dosya görüntüleyicide açılır; telefonda aynı yola dokunmak onu tam ekran açar. -
Ajanın ürettiğini açın -
Terminaldeki bir yola tıklayın ya da sohbette dokunun: dosya hemen orada açılır. -
- Canlı kayıt: telefonda ⋯ menüsünden Yeni worktree; dal önceden doludur, ajan seçilir ve oluşturulur; tarayıcıda yeni worktree ilk ajanın yanında belirir. -
İkinci bir ajan dallandırın -
Telefondan ⋯ → Yeni worktree: dal doldurulmuş olur, bir ajan seçersiniz ve ilk ajanın yanında başlar. -
- -

Her kare, masaüstü ve bir telefonun aynı anda gerçek zamanlı kaydıdır; kesmesiz. Tam video için birine tıklayın.

- -- **Sohbet ve terminal, tek panelde** — yerel Claude Code, Codex, omp, omo, gjc, pi, Hermes ve açıkça tanımlanmış Devin CLI dökümleri, ayrıca herdr'ın tanıdığı OpenCode 2 oturumları; canlı terminal bir tık uzağınızda. Ajan çalışırken sıradaki mesajı yazın: sıradaki turla birlikte girer ya da Şimdi gönder ile hemen. [Desteklenen ajanlar →](docs/guide.md#supported-agents) -- **Tek dokunuşla onaylayın** — onaylar, sorular ve plan menüleri karta dönüşür; yanıtınız gönderilmeden güncel oldukları denetlenir. -- **Ne zaman gerekli olduğunuzu bilin** — her panel için canlı durum, uygulama açıkken gelen uyarılar ve bir ajan girdi istediğinde ya da bittiğinde, uygulama kapalıyken bile anlık uyarılar. -- **Telefonunuza kurun** — klavyenin üzerinde yeniden düzenlenebilen ve Ayarlar'da kendi bileşimlerinizle genişletilebilen tuş çubuğu (Esc, Tab, Ctrl, Alt, Shift, Enter, yön tuşları) olan bir PWA ve Tailscale adresinize QR kodu. [Telefon kurulumu →](docs/guide.md#on-your-phone) -- **Yazmak yerine konuşun** — sohbete ya da terminal satırına dikte edin, Korece ve İngilizce karışık. Sözcükleri ajana göndermeden önce inceleyin; yazıya çevirme kendi OpenAI anahtarınızı ya da tarayıcının konuşma tanımasını kullanır. -- **İş akışınızı koruyun** — ajanların sahibi herdr'dır; bu uygulama onlara bağlanır. Ayarlar'dan, onları durdurmadan güncelleyin; her güncellemenin getirdikleri not olarak gelir. Yeni sekmeler ve worktree'ler satırın ⋯ menüsünden gelir. [Tüm özellikler →](docs/guide.md#features) - ---- - -## kurulum - -```bash -curl -fsSL https://herdrweb.dev/install.sh | sh -``` - -Linux (x64, arm64) veya macOS. Eksik herdr 0.9.0+, Bun 1.4+ ve Node 18+ önkoşullarını kullanıcınız için kurar, sonra uygulamayı bir herdr eklentisi olarak kurar. Var olan herdr kurulumu 0.9.0'dan eskiyse, kurulumu yeniden çalıştırmadan önce herdr'ı kendiniz güncelleyip yeniden başlatın. Varsayılan dinleme adresiyle ve Tailscale çalışırken, başarılı HTTPS kurulumu bir tailnet adresi ve QR kodu sağlar. Kendi cihazlarınız kodsuz girer: `tailscale serve` oturumunuzu belirtir. Kendi telefonunuz yine de eşleştirme istenirse, `HERDR_WEB_TAILSCALE_SERVE_ONLY=1` tek oturumun sahibi olduğu bir tailnet'te kodsuz girişe izin verir — ama yalnızca başka hiçbir şey (ör. genel bir proxy veya tünel) bu porta erişmiyorsa ([Erişim ve güvenlik](docs/guide.md#access-and-safety)). - -Windows x64, PowerShell'de: - -```powershell -irm https://herdrweb.dev/install.ps1 | iex -``` - -[Git for Windows](https://git-scm.com/download/win) gerektirir. Eksik herdr ve Bun'ı kullanıcınız için kurar, sonra aynı eklentiyi kurar. Node ya da WSL gerekmez. Telefon erişimi için herdr içinde **Telefon kurulumu**'nu açın. Windows terminalleri, herdr orada terminal bağlamayı destekleyene dek [ekran aynasını](docs/remote-pcs.md#windows-pcs) kullanır: yazma ve sabit ızgara ile. - -

- Kurulum çıktısı: Bun, Node ve herdr eklentisi kurulur; tailscale serve uygulamayı yayınlar ve telefon için bir QR kodu belirir. -

- -Önkoşullarınız zaten var mı? Yalnızca eklentiyi kurun: - -```bash -herdr plugin install devswha/herdr-web-ui -``` - -herdr çalışırken **[localhost:7317](http://localhost:7317)** adresini açın. Bir panel seçin ya da **Yeni çalışma alanı** ile bir ajan başlatın. Telefonunuzu kullanmak için kurulumun QR kodunu okutun ve uygulamayı ana ekranınıza ekleyin. [Hızlı başlangıç →](docs/guide.md#quick-start) - -Sunucu varsayılan olarak `127.0.0.1` üzerinde dinler. Başka bir cihazdan erişim için [telefon kurulumu](docs/guide.md#on-your-phone) ve [erişim ve güvenlik](docs/guide.md#access-and-safety) bölümlerine bakın. - -## sss - -**Claude Code'u ya da Codex'i telefonumdan kullanabilir miyim?** - -Evet. Ajanı bilgisayarınızdaki bir [herdr](https://github.com/herdrdev/herdr) panelinde çalıştırın; bu uygulama aynı paneli telefonunuzun tarayıcısında gösterir: ajanın kendi dökümü sohbet olarak, onayları ve soruları dokunduğunuz kartlar olarak, canlı terminal bir dokunuş uzağınızda. PWA olarak ana ekrana kurulur ve bir ajan size ihtiyaç duyduğunda anlık uyarı gönderir. [Telefon kurulumu →](docs/guide.md#on-your-phone) - -**Sohbet görünümü hangi ajanları destekliyor?** - -Claude Code, Codex, omp, omo, gjc, pi ve Hermes kendi oturum depolarından okunur. OpenCode 2 için sohbet, herdr'ın OpenCode'un veritabanından bildirdiği oturumu okur; ana ekran ve OpenCode 1.x bunun yerine canlı terminali kullanır. Devin CLI, herdr ya da çalışan süreç oturumunu adlandırdığında (`devin --resume ` gibi) sohbettir; düz bir `devin` canlı terminali gösterir. herdr panelindeki diğer her program canlı terminali ve durumunu alır. [Desteklenen ajanlar →](docs/guide.md#supported-agents) - -**herdr'ın kendi TUI'sının yerini alır mı?** - -Hayır. İkisi de aynı terminallere aynı anda bağlanır; panel masanızda, tarayıcıda ve telefonda canlı kalır. Hiçbir şey durmaz ya da devredilmez. [TUI ve tarayıcı →](docs/guide.md#faq) - -**Tailscale gerekir mi?** - -Hayır. Tailscale, bir SSH tüneli, bir VPN ya da kendi HTTPS proxy'niz bilgisayara bir yol sağlayabilir. Kurulum ve anlık uyarılar HTTPS ya da localhost gibi güvenli bir bağlam gerektirir; temel gezinme LAN üzerinde düz HTTP ile de çalışır. [Seçenekler →](docs/guide.md#faq) - -**Kodum ya da konuşmam makinemden çıkar mı?** - -Oturum dosyaları her ajanı çalıştıran bilgisayarda kalır; içerikleri yalnızca bağlanan tarayıcılara sunulur. Uygulamanın kendi barındırılan aktarıcısı ya da hesap servisi yoktur. İsteğe bağlı ses girdisi, sesi (ve düzeltirken metni) yapılandırılan sağlayıcıya gönderir; etkinleştirilen kullanım göstergeleri sağlayıcı API'lerine bağlanır. Güncellemeler, uzak bilgisayar kurulumu ve anlık uyarılar da dış servisleri kullanabilir. Uygulama kurulduğunda ve her güncellendiğinde anonim bir sayaç gönderir (sürüm, işletim sistemi, nasıl kurulduğu ve rastgele bir kimlik; IP adresi saklanmaz); **Ayarlar → Hakkında → Anonim kullanım sayaçları** ne gönderildiğini gösterir ve kapatır. Ajanların kendi model bağlantıları yapılandırmalarına bağlıdır. [Veri akışı ve erişim →](docs/guide.md#faq) - -**Windows'ta çalışır mı?** - -Evet, WSL olmadan Windows x64 üzerinde. herdr orada terminale bağlanmayı destekleyene dek terminal, yazma ve sabit ızgaralı bir [ekran aynasıdır](docs/remote-pcs.md#windows-pcs). - -**collie, roamgate ya da herdr-remote'dan farkı ne?** - -Bunlar da herdr için telefon ya da tarayıcı istemcileri. Bu uygulama ajanın kendi dökümünü okur; böylece panel, terminal çıktısı yerine tur başına katlanmış iş içeren bir sohbettir ve diğer bilgisayarlar kenar çubuğundan SSH ile katılır. Tünel getirmez ve yalnızca herdr'ı yönetir: tmux ya da zellij, diff'ler, Telegram ya da kutudan çıkan bir tünel istiyorsanız, diğerlerinden biri daha uygun olabilir. [Tam karşılaştırma →](docs/guide.md#faq) - -**Happy, Paseo ya da CloudCLI UI'dan farkı ne?** - -O projeler ajan oturumları başlatmanın ya da yönetmenin kendi yollarını sunar. Bu uygulama zaten çalıştırdığınız herdr panelleriyle çalışır ve aynı terminalleri TUI'da ve tarayıcıda kullanılabilir tutar. herdr kullanmıyorsanız o projelerin kurulum seçeneklerini ve ajan desteğini karşılaştırın. [Tam karşılaştırma →](docs/guide.md#faq) - -## dokümanlar - -[Kullanıcı kılavuzuyla](docs/guide.md) başlayın: [hızlı başlangıç](docs/guide.md#quick-start) · [desteklenen ajanlar](docs/guide.md#supported-agents) · [özellikler](docs/guide.md#features) · [telefon](docs/guide.md#on-your-phone) · [uzak bilgisayarlar](docs/remote-pcs.md) · [erişim ve güvenlik](docs/guide.md#access-and-safety) · [yapılandırma](docs/guide.md#configuration) · [klavye kısayolları](docs/guide.md#keyboard-shortcuts) · [SSS](docs/guide.md#faq). - -Daha yakından bakmak için: [nasıl çalışır](docs/guide.md#how-it-works) · [sohbet dökümleri](docs/chat-mode-audit.md) · [terminal akış denetimi](docs/terminal-flow-control.md) · [uygulama güncellemeleri](docs/app-updates.md) · [değişiklik günlüğü](CHANGELOG.md). - -## teşekkürler - -[herdr](https://github.com/herdrdev/herdr) üzerine kurulmuştur; ilham [chatmux](https://github.com/devswha/chatmux)'tan; gücünü [xterm.js](https://xtermjs.org), [React](https://react.dev), [Bun](https://bun.sh) ve [Lucide](https://lucide.dev)'den alır. - -Katkıda bulunan herkese teşekkürler. - -## ajan yönergeleri - -Birine uygulama kurduruyor musunuz? [INSTALL.md](INSTALL.md)'yi izleyin. Depo değişiklikleri için [CONTRIBUTING.md](CONTRIBUTING.md), [inceleme kuralları](.github/REVIEW.md) ve [AGENTS.md](AGENTS.md) geçerlidir. - -## geliştirme - -```bash -git clone https://github.com/devswha/herdr-web-ui.git -cd herdr-web-ui -bun install - -bun run server # API + WebSocket, :7317 -bun run dev # Vite, :5173; ikinci bir terminalde çalıştırın -``` - -```bash -bun run typecheck -bun run test:unit # herdr gerekmez -bun test # yalıtılmış herdr test oturumu -bun run test:ui # tarayıcı regresyon denetimleri -``` - -Değişiklik göndermek için [CONTRIBUTING.md](CONTRIBUTING.md), testler/medya/sürümler için [geliştirme](docs/development.md), arayüz kuralları için [DESIGN.md](DESIGN.md). Güvenlik sorunlarını özel olarak bildirin: [SECURITY.md](SECURITY.md). - -## lisans - -[MIT](LICENSE). Copyright © 2026 devswha. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6ee136aac..d694ce9ef 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -2,6 +2,117 @@ herdr web ui includes material from the projects below, under their own licenses. +## Legend List + +The chat timeline uses [`@legendapp/list`](https://github.com/LegendApp/legend-list) 3.3.5. + +``` +MIT License + +Copyright (c) 2022 Moo.do LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +### t3code DOM reorder adaptation + +The web ESM entry adapts t3code's state-preserving `Element.moveBefore` approach +from revision `bed923986fbcbe8279f23b9367efde1596bde1f9`, under the MIT License +printed above, with this copyright notice: + +``` +Copyright (c) 2026 T3 Tools Inc. +``` + +### use-sync-external-store + +Legend List also uses `use-sync-external-store`, under the MIT License printed above, +with this copyright notice: + +``` +Copyright (c) Meta Platforms, Inc. and affiliates. +``` + +## T3 Code + +These herdr web ui files adapt logic from [t3code](https://github.com/pingdotgg/t3code) at revision +`e82a1ce3f369f113b0b33dff8249569c345e30e8`, under the MIT License printed above, with this +copyright notice: + +``` +Copyright (c) 2026 T3 Tools Inc. +``` + +- `src/lib/chatTimestamp.ts` <- `apps/web/src/timestampFormat.ts` +- `src/lib/followScroll.ts` <- `apps/web/src/components/chat/MessagesTimeline.tsx` +- `src/lib/composerMenuHighlight.ts` <- `apps/web/src/components/chat/composerMenuHighlight.ts` +- `src/lib/composerImageMime.ts` <- `apps/web/src/components/chat/composerAttachmentFiles.ts` +- `src/lib/terminalQuote.ts` <- `apps/web/src/lib/terminalContext.ts` +- `src/lib/imageBox.ts`, `src/components/Markdown.tsx` <- `apps/web/src/components/ChatMarkdown.tsx` +- `src/lib/skillMentions.ts` <- `apps/web/src/components/chat/SkillInlineText.tsx` +- `src/components/RepoDiffViewer.tsx`, `src/components/Skeleton.tsx` <- + `apps/web/src/components/DiffFilePathCopyButton.tsx`, + `apps/web/src/components/DiffPanelShell.tsx` +- `src/lib/paletteSettings.ts` <- `apps/web/src/components/settings/settingsSearch.ts`, + `apps/web/src/components/CommandPalette.logic.ts` +- `src/components/AnimatedHeight.tsx`, `src/lib/animatedHeight.ts` <- + `apps/web/src/components/AnimatedHeight.tsx`, `apps/web/src/lib/observeResize.ts` +- `src/lib/workspacePath.ts` <- `apps/web/src/filePathDisplay.ts` +- `src/lib/fileIcons.ts`, `src/components/FileGlyph.tsx` <- `apps/web/src/pierre-icons.ts`, + `apps/web/src/components/chat/PierreEntryIcon.tsx` +- `src/styles.css` <- `apps/web/src/index.css` +- `src/lib/composerNotices.ts` <- `apps/web/src/components/chat/ComposerBannerStack.tsx` +- `src/lib/changeTree.ts`, `src/components/RepoChanges.tsx` <- + `apps/web/src/components/diffs/diffFileTree.logic.ts`, + `apps/web/src/components/diffs/DiffFileTree.tsx` +- `src/lib/installedFonts.ts`, `src/components/SettingsDialog.tsx` <- + `apps/web/src/components/settings/FontFamilyPicker.tsx`, `apps/web/src/appearanceFonts.ts` +- `src/lib/idleLease.ts`, `src/components/DiffWorkerPool.tsx` <- + `apps/web/src/components/DiffWorkerPoolProvider.tsx` +- `src/lib/paletteFolders.ts`, `src/components/CommandPalette.tsx` <- + `apps/web/src/components/CommandPalette.logic.ts`, + `packages/client-runtime/src/state/filesystem.ts` +- `src/components/ImageLightbox.tsx`, `src/lib/imageViewer.ts`, `src/lib/clipboard.ts` <- + `apps/web/src/components/chat/ZoomableImage.tsx`, + `apps/web/src/components/chat/ExpandedImageDialog.tsx`, + `apps/web/src/components/chat/ExpandedImagePreview.ts`, + `apps/web/src/components/media/mediaContent.ts`, + `apps/web/src/components/media/MediaActions.tsx` +- `src/lib/markdownClipboard.ts`, `src/components/ChatTimeline.tsx` <- + `apps/web/src/markdown-clipboard.ts`, `apps/web/src/components/chat/MessagesTimeline.tsx` +- `src/lib/typography.ts`, `src/lib/settings.ts`, `src/components/SettingsDialog.tsx` <- + `apps/web/src/appearanceFonts.ts`, + `apps/web/src/components/settings/SettingsPanels.tsx` +- `src/components/Composer.tsx`, `src/components/Composer.css`, `src/lib/composerStrip.ts` <- + `apps/web/src/components/chat/ComposerSurface.tsx`, + `apps/web/src/components/chat/ComposerControl.tsx`, + `apps/web/src/components/chat/ChatComposer.tsx` +- `src/lib/chatScrollMemory.ts`, `src/components/ChatView.tsx` <- + `apps/web/src/components/chat/timelineScrollAnchoring.ts`, + `apps/web/src/components/chat/MessagesTimeline.tsx` +- `src/lib/chatMinimap.ts`, `src/components/ChatMinimap.tsx` <- + `apps/web/src/components/chat/MessagesTimeline.logic.ts`, + `apps/web/src/components/chat/timelineMinimapItems.ts` +- `src/lib/userMessageFold.ts`, `src/components/ChatView.tsx` <- + `apps/web/src/components/chat/MessagesTimeline.logic.ts`, + `apps/web/src/components/chat/MessagesTimeline.tsx` + ## TanStack Highlight Code highlighting in the client and its worker uses @@ -31,6 +142,513 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## Pierre diffs + +The file viewer draws a source of more than 2 000 lines as a window of rows from +[`@pierre/diffs`](https://github.com/pierrecomputer/pierre) 1.5.2, colored in the package's own +worker. The built client in `dist/` carries it with `@pierre/theme` 2.0.0 (its code themes) and +`@pierre/theming` 1.0.1, all three under the Apache License 2.0, and with the packages they bundle, +listed under [Pierre's bundled dependencies](#pierres-bundled-dependencies) below. The three Pierre +packages ship this license; its closing lines are their copyright notice. + +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, and + distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by the + copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all other + entities that control, are controlled by, or are under common control with + that entity. For the purposes of this definition, "control" means (i) the + power, direct or indirect, to cause the direction or management of such + entity, whether by contract or otherwise, or (ii) ownership of fifty percent + (50%) or more of the outstanding shares, or (iii) beneficial ownership of + such entity. + + "You" (or "Your") shall mean an individual or Legal Entity exercising + permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation source, and + configuration files. + + "Object" form shall mean any form resulting from mechanical transformation + or translation of a Source form, including but not limited to compiled + object code, generated documentation, and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object form, + made available under the License, as indicated by a copyright notice that is + included in or attached to the work (an example is provided in the Appendix + below). + + "Derivative Works" shall mean any work, whether in Source or Object form, + that is based on (or derived from) the Work and for which the editorial + revisions, annotations, elaborations, or other modifications represent, as a + whole, an original work of authorship. For the purposes of this License, + Derivative Works shall not include works that remain separable from, or + merely link (or bind by name) to the interfaces of, the Work and Derivative + Works thereof. + + "Contribution" shall mean any work of authorship, including the original + version of the Work and any modifications or additions to that Work or + Derivative Works thereof, that is intentionally submitted to Licensor for + inclusion in the Work by the copyright owner or by an individual or Legal + Entity authorized to submit on behalf of the copyright owner. For the + purposes of this definition, "submitted" means any form of electronic, + verbal, or written communication sent to the Licensor or its + representatives, including but not limited to communication on electronic + mailing lists, source code control systems, and issue tracking systems that + are managed by, or on behalf of, the Licensor for the purpose of discussing + and improving the Work, but excluding communication that is conspicuously + marked or otherwise designated in writing by the copyright owner as "Not a + Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity on + behalf of whom a Contribution has been received by Licensor and subsequently + incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this + License, each Contributor hereby grants to You a perpetual, worldwide, + non-exclusive, no-charge, royalty-free, irrevocable copyright license to + reproduce, prepare Derivative Works of, publicly display, publicly perform, + sublicense, and distribute the Work and such Derivative Works in Source or + Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this + License, each Contributor hereby grants to You a perpetual, worldwide, + non-exclusive, no-charge, royalty-free, irrevocable (except as stated in + this section) patent license to make, have made, use, offer to sell, sell, + import, and otherwise transfer the Work, where such license applies only to + those patent claims licensable by such Contributor that are necessarily + infringed by their Contribution(s) alone or by combination of their + Contribution(s) with the Work to which such Contribution(s) was submitted. + If You institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work or a + Contribution incorporated within the Work constitutes direct or contributory + patent infringement, then any patent licenses granted to You under this + License for that Work shall terminate as of the date such litigation is + filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or + Derivative Works thereof in any medium, with or without modifications, and + in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a + copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating + that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You + distribute, all copyright, patent, trademark, and attribution notices from + the Source form of the Work, excluding those notices that do not pertain to + any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, + then any Derivative Works that You distribute must include a readable copy + of the attribution notices contained within such NOTICE file, excluding + those notices that do not pertain to any part of the Derivative Works, in at + least one of the following places: within a NOTICE text file distributed as + part of the Derivative Works; within the Source form or documentation, if + provided along with the Derivative Works; or, within a display generated by + the Derivative Works, if and wherever such third-party notices normally + appear. The contents of the NOTICE file are for informational purposes only + and do not modify the License. You may add Your own attribution notices + within Derivative Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional attribution + notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may + provide additional or different license terms and conditions for use, + reproduction, or distribution of Your modifications, or for any such + Derivative Works as a whole, provided Your use, reproduction, and + distribution of the Work otherwise complies with the conditions stated in + this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any + Contribution intentionally submitted for inclusion in the Work by You to the + Licensor shall be under the terms and conditions of this License, without + any additional terms or conditions. Notwithstanding the above, nothing + herein shall supersede or modify the terms of any separate license agreement + you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, + trademarks, service marks, or product names of the Licensor, except as + required for reasonable and customary use in describing the origin of the + Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in + writing, Licensor provides the Work (and each Contributor provides its + Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied, including, without limitation, any + warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or + FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining + the appropriateness of using or redistributing the Work and assume any risks + associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in + tort (including negligence), contract, or otherwise, unless required by + applicable law (such as deliberate and grossly negligent acts) or agreed to + in writing, shall any Contributor be liable to You for damages, including + any direct, indirect, special, incidental, or consequential damages of any + character arising as a result of this License or out of the use or inability + to use the Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all other + commercial damages or losses), even if such Contributor has been advised of + the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or + Derivative Works thereof, You may choose to offer, and charge a fee for, + acceptance of support, warranty, indemnity, or other liability obligations + and/or rights consistent with this License. However, in accepting such + obligations, You may act only on Your own behalf and on Your sole + responsibility, not on behalf of any other Contributor, and only if You + agree to indemnify, defend, and hold each Contributor harmless for any + liability incurred by, or claims asserted against, such Contributor by + reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Pierre Computer Company + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +``` + +`@pierre/theme` also ships this notice, for the theme it was built on: + +```` +This theme was built on top of +[GitHub's Visual Studio Code Theme](https://github.com/primer/github-vscode-theme), +reusing its technique and build tooling, which we have since iterated on for +more specific language tokens. + +`@pierre/theme` is licensed under Apache-2.0 (see `LICENSE`). The original MIT +license for `primer/github-vscode-theme` is included below for attribution. + +Original license for `primer/github-vscode-theme`: + +``` +MIT License + +Copyright (c) 2020 Primer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` +```` + +### Pierre's bundled dependencies + +The chunks of `@pierre/diffs` in `dist/` carry the code below. Each license text follows once, +under the packages that share it, with every copyright line the packages ship. + +| Package | Upstream | Version | License | +|---|---|---|---| +| shiki, @shikijs/core, @shikijs/engine-javascript, @shikijs/engine-oniguruma, @shikijs/langs, @shikijs/primitive, @shikijs/themes, @shikijs/transformers, @shikijs/types | https://github.com/shikijs/shiki | 4.5.0 | MIT | +| @shikijs/vscode-textmate | https://github.com/shikijs/vscode-textmate | 10.0.2 | MIT | +| diff (jsdiff) | https://github.com/kpdecker/jsdiff | 9.0.0 | BSD 3-Clause | +| lru_map | https://github.com/rsms/js-lru | 0.4.1 | MIT | +| @ungap/structured-clone | https://github.com/ungap/structured-clone | 1.4.0 | ISC | +| dequal | https://github.com/lukeed/dequal | 2.0.3 | MIT | +| oniguruma-to-es | https://github.com/slevithan/oniguruma-to-es | 4.3.6 | MIT | +| oniguruma-parser | https://github.com/slevithan/oniguruma-parser | 0.12.2 | MIT | +| regex | https://github.com/slevithan/regex | 6.1.0 | MIT | +| regex-recursion | https://github.com/slevithan/regex-recursion | 6.0.2 | MIT | +| regex-utilities | https://github.com/slevithan/regex-utilities | 2.3.0 | MIT | +| hast-util-to-html | https://github.com/syntax-tree/hast-util-to-html | 9.0.5 | MIT | +| hast-util-whitespace | https://github.com/syntax-tree/hast-util-whitespace | 3.0.0 | MIT | +| mdast-util-to-hast | https://github.com/syntax-tree/mdast-util-to-hast | 13.2.1 | MIT | +| unist-util-is | https://github.com/syntax-tree/unist-util-is | 6.0.1 | MIT | +| unist-util-position | https://github.com/syntax-tree/unist-util-position | 5.0.0 | MIT | +| unist-util-stringify-position | https://github.com/syntax-tree/unist-util-stringify-position | 4.0.0 | MIT | +| unist-util-visit | https://github.com/syntax-tree/unist-util-visit | 5.1.0 | MIT | +| unist-util-visit-parents | https://github.com/syntax-tree/unist-util-visit-parents | 6.0.2 | MIT | +| vfile | https://github.com/vfile/vfile | 6.0.3 | MIT | +| vfile-message | https://github.com/vfile/vfile-message | 4.0.3 | MIT | +| micromark-util-character, micromark-util-encode, micromark-util-sanitize-uri, micromark-util-symbol, micromark-util-types | https://github.com/micromark/micromark | 2.1.1, 2.0.1, 2.0.1, 2.0.1, 2.0.3 | MIT | +| ccount | https://github.com/wooorm/ccount | 2.0.1 | MIT | +| character-entities-html4 | https://github.com/wooorm/character-entities-html4 | 2.1.0 | MIT | +| character-entities-legacy | https://github.com/wooorm/character-entities-legacy | 3.0.0 | MIT | +| comma-separated-tokens | https://github.com/wooorm/comma-separated-tokens | 2.0.3 | MIT | +| devlop | https://github.com/wooorm/devlop | 1.1.0 | MIT | +| html-void-elements | https://github.com/wooorm/html-void-elements | 3.0.0 | MIT | +| property-information | https://github.com/wooorm/property-information | 7.2.0 | MIT | +| space-separated-tokens | https://github.com/wooorm/space-separated-tokens | 2.0.2 | MIT | +| stringify-entities | https://github.com/wooorm/stringify-entities | 4.0.4 | MIT | +| trim-lines | https://github.com/wooorm/trim-lines | 3.0.1 | MIT | +| zwitch | https://github.com/wooorm/zwitch | 2.0.4 | MIT | + +#### Shiki 4.5.0: shiki, @shikijs/core, @shikijs/engine-javascript, @shikijs/engine-oniguruma, @shikijs/langs, @shikijs/primitive, @shikijs/themes, @shikijs/transformers, @shikijs/types + +``` +MIT License + +Copyright (c) 2021 Pine Wu +Copyright (c) 2023 Anthony Fu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +#### @shikijs/vscode-textmate 10.0.2 + +``` +The MIT License (MIT) + +Copyright (c) Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +#### diff 9.0.0 (jsdiff) + +``` +BSD 3-Clause License + +Copyright (c) 2009-2015, Kevin Decker +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +#### lru_map 0.4.1 + +The package ships its license in its README: + +``` +Copyright (c) 2010-2016 Rasmus Andersson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + +#### @ungap/structured-clone 1.4.0 + +``` +ISC License + +Copyright (c) 2021, Andrea Giammarchi, @WebReflection + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +``` + +#### dequal 2.0.3 + +``` +The MIT License (MIT) + +Copyright (c) Luke Edwards (lukeed.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + +#### Steven Levithan's regex packages + +The MIT text below is shipped by each with its own copyright line: + +- oniguruma-to-es 4.3.6: `Copyright (c) 2024-2026 Steven Levithan` +- oniguruma-parser 0.12.2: `Copyright (c) 2025-2026 Steven Levithan` +- regex 6.1.0, regex-recursion 6.0.2: `Copyright (c) 2025 Steven Levithan` +- regex-utilities 2.3.0: `Copyright (c) 2024 Steven Levithan` + +``` +MIT License + +Copyright (c) 2025 Steven Levithan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +#### Titus Wormer's unified and micromark packages + +The MIT text below is shipped by each with its own copyright line: + +- hast-util-to-html 9.0.5, micromark-util-character 2.1.1, micromark-util-encode 2.0.1, micromark-util-sanitize-uri 2.0.1, micromark-util-symbol 2.0.1, micromark-util-types 2.0.3, vfile-message 4.0.3: `Copyright (c) Titus Wormer ` +- ccount 2.0.1, character-entities-html4 2.1.0, character-entities-legacy 3.0.0, unist-util-is 6.0.1, unist-util-position 5.0.0, unist-util-visit 5.1.0, vfile 6.0.3: `Copyright (c) 2015 Titus Wormer ` +- stringify-entities 4.0.4, trim-lines 3.0.1: `Copyright (c) 2015 Titus Wormer ` +- comma-separated-tokens 2.0.3, hast-util-whitespace 3.0.0, html-void-elements 3.0.0, mdast-util-to-hast 13.2.1, space-separated-tokens 2.0.2, unist-util-stringify-position 4.0.0, unist-util-visit-parents 6.0.2, zwitch 2.0.4: `Copyright (c) 2016 Titus Wormer ` +- devlop 1.1.0: `Copyright (c) 2023 Titus Wormer ` +- property-information 7.2.0: `Copyright (c) Titus Wormer ` + +``` +(The MIT License) + +Copyright (c) Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + ## LobeHub Icons The agent marks in `src/components/agentSvgMarks.ts` other than droid and maki, which the diff --git a/bun.lock b/bun.lock index bc22cb520..b15c7106a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,9 @@ "": { "name": "herdr-web-ui", "dependencies": { + "@legendapp/list": "3.3.5", "@lydell/node-pty": "1.1.0", + "@pierre/diffs": "1.5.2", "@tanstack/highlight": "^1.0.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-unicode-graphemes": "0.4.0", @@ -15,67 +17,31 @@ "lucide-react": "^1.47.0", "mermaid": "^12.1.0", "qrcode-generator": "^2.0.4", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.3.0", + "react-dom": "^19.3.0", "web-push": "^3.6.7", }, "devDependencies": { "@types/bun": "^1.1.14", "@types/katex": "^0.16.8", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@types/web-push": "^3.6.4", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^6.1.2", "playwright-core": "^1.49.1", - "typescript": "^5.7.2", - "vite": "^6.0.5", + "typescript": "^7.0.2", + "typescript-api": "npm:typescript@^6.0.3", + "vite": "^8.3.4", }, }, }, "patchedDependencies": { "@xterm/xterm@5.5.0": "patches/@xterm%2Fxterm@5.5.0.patch", + "@legendapp/list@3.3.5": "patches/@legendapp%2Flist@3.3.5.patch", }, "packages": { "@antfu/install-pkg": ["@antfu/install-pkg@2.1.0", "", { "dependencies": { "package-manager-detector": "^1.8.0", "tinyexec": "^1.3.1" } }, "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw=="], - "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - - "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - - "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - - "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], - - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - - "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - - "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], - - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], - - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], - - "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - - "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], - - "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], - "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@13.2.0", "", { "dependencies": { "@chevrotain/gast": "13.2.0", "@chevrotain/types": "13.2.0" } }, "sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg=="], @@ -88,71 +54,11 @@ "@chevrotain/utils": ["@chevrotain/utils@13.2.0", "", {}, "sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], "@iconify/utils": ["@iconify/utils@3.1.7", "", { "dependencies": { "@antfu/install-pkg": "^2.0.1", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@legendapp/list": ["@legendapp/list@3.3.5", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ=="], "@lydell/node-pty": ["@lydell/node-pty@1.1.0", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.1.0", "@lydell/node-pty-darwin-x64": "1.1.0", "@lydell/node-pty-linux-arm64": "1.1.0", "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0" } }, "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw=="], @@ -170,69 +76,65 @@ "@mermaid-js/parser": ["@mermaid-js/parser@2.0.1", "", { "dependencies": { "@chevrotain/types": "~13.2.0" } }, "sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA=="], - "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + "@oxc-project/types": ["@oxc-project/types@0.153.0", "", {}, "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@pierre/diffs": ["@pierre/diffs@1.5.2", "", { "dependencies": { "@pierre/theme": "2.0.0", "@pierre/theming": "1.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-QVxIWQEnNoko22k63maERzozd29+FtaUNpRrZiw603WMe5R1mFSED+jMtPkcVP3SDYS1DWg5K7sa7sJbr3xkNQ=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.3", "", { "os": "android", "cpu": "arm" }, "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw=="], + "@pierre/theme": ["@pierre/theme@2.0.0", "", {}, "sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.3", "", { "os": "android", "cpu": "arm64" }, "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ=="], + "@pierre/theming": ["@pierre/theming@1.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.1.0 || ^2.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.13", "", { "os": "android", "cpu": "arm" }, "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.13", "", { "os": "android", "cpu": "arm64" }, "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.3", "", { "os": "linux", "cpu": "arm" }, "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.13", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.3", "", { "os": "linux", "cpu": "arm" }, "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.13", "", { "os": "linux", "cpu": "arm" }, "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.13", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.13", "", { "os": "linux", "cpu": "s390x" }, "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.13", "", { "os": "linux", "cpu": "x64" }, "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.13", "", { "os": "linux", "cpu": "x64" }, "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.13", "", { "os": "none", "cpu": "arm64" }, "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.13", "", { "os": "win32", "cpu": "x64" }, "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.3", "", { "os": "linux", "cpu": "x64" }, "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ=="], + "@shikijs/core": ["@shikijs/core@4.5.0", "", { "dependencies": { "@shikijs/primitive": "4.5.0", "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.3", "", { "os": "none", "cpu": "arm64" }, "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q=="], + "@shikijs/langs": ["@shikijs/langs@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0" } }, "sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA=="], + "@shikijs/primitive": ["@shikijs/primitive@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.3", "", { "os": "win32", "cpu": "x64" }, "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ=="], + "@shikijs/themes": ["@shikijs/themes@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0" } }, "sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.3", "", { "os": "win32", "cpu": "x64" }, "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA=="], - - "@tanstack/highlight": ["@tanstack/highlight@1.0.0", "", {}, "sha512-nrg2iZSQUZlDZGTPKh/CNlg6Orm3vFHIaIi/9DqPC1rUpfe/M6rD2iokrckWTc2Z5jYDz185BK76GUh9VIjQRw=="], + "@shikijs/transformers": ["@shikijs/transformers@4.5.0", "", { "dependencies": { "@shikijs/core": "4.5.0", "@shikijs/types": "4.5.0" } }, "sha512-LM7twi043qCC3gRyGQfjoC2/sowzbEltWuruyRtT3RlBlk1hKzWk9ETnU7RcDBoVWQBmC8Mebnr7p28W7kPoaA=="], - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + "@shikijs/types": ["@shikijs/types@4.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw=="], - "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], - - "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@tanstack/highlight": ["@tanstack/highlight@1.0.0", "", {}, "sha512-nrg2iZSQUZlDZGTPKh/CNlg6Orm3vFHIaIi/9DqPC1rUpfe/M6rD2iokrckWTc2Z5jYDz185BK76GUh9VIjQRw=="], "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], @@ -298,27 +200,71 @@ "@types/d3-zoom": ["@types/d3-zoom@3.0.9", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA=="], - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], - "@types/node": ["@types/node@22.20.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], - "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + "@types/node": ["@types/node@22.20.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A=="], - "@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="], + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], - "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.152.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw=="], "@xterm/addon-fit": ["@xterm/addon-fit@0.10.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="], @@ -332,23 +278,23 @@ "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.24", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg=="], - "bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="], - "browserslist": ["browserslist@4.29.0", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="], - "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], - "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], "chevrotain": ["chevrotain@13.2.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "13.2.0", "@chevrotain/gast": "13.2.0", "@chevrotain/regexp-to-ast": "13.2.0", "@chevrotain/types": "13.2.0", "@chevrotain/utils": "13.2.0" } }, "sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA=="], - "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], @@ -432,28 +378,34 @@ "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "dompurify": ["dompurify@3.4.16", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - "electron-to-chromium": ["electron-to-chromium@1.5.430", "", {}, "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ=="], - "elkjs": ["elkjs@0.9.3", "", {}, "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ=="], "es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -466,12 +418,6 @@ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], @@ -482,18 +428,52 @@ "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], - "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + + "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], "lucide-react": ["lucide-react@1.47.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg=="], "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + "mermaid": ["mermaid@12.1.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^2.0.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "chevrotain": "~13.2.0", "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.21", "dompurify": "^3.4.12", "elkjs": "^0.9.3", "es-toolkit": "^1.45.1", "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-wlVCp+8eTupfCeeFvoZNNiTuHrvag0P2jz/ILgb/f/6jkVokUefOcujefi8qUe/j2asHiSePncVsz/xzzA80LQ=="], + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.3", "", {}, "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -502,7 +482,9 @@ "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], - "node-releases": ["node-releases@2.0.55", "", {}, "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], "package-manager-detector": ["package-manager-detector@1.9.0", "", {}, "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA=="], @@ -518,19 +500,25 @@ "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], - "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + "postcss": ["postcss@8.5.29", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="], + + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="], - "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], - "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], - "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "rollup": ["rollup@4.63.3", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.3", "@rollup/rollup-android-arm64": "4.63.3", "@rollup/rollup-darwin-arm64": "4.63.3", "@rollup/rollup-darwin-x64": "4.63.3", "@rollup/rollup-freebsd-arm64": "4.63.3", "@rollup/rollup-freebsd-x64": "4.63.3", "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", "@rollup/rollup-linux-arm-musleabihf": "4.63.3", "@rollup/rollup-linux-arm64-gnu": "4.63.3", "@rollup/rollup-linux-arm64-musl": "4.63.3", "@rollup/rollup-linux-loong64-gnu": "4.63.3", "@rollup/rollup-linux-loong64-musl": "4.63.3", "@rollup/rollup-linux-ppc64-gnu": "4.63.3", "@rollup/rollup-linux-ppc64-musl": "4.63.3", "@rollup/rollup-linux-riscv64-gnu": "4.63.3", "@rollup/rollup-linux-riscv64-musl": "4.63.3", "@rollup/rollup-linux-s390x-gnu": "4.63.3", "@rollup/rollup-linux-x64-gnu": "4.63.3", "@rollup/rollup-linux-x64-musl": "4.63.3", "@rollup/rollup-openbsd-x64": "4.63.3", "@rollup/rollup-openharmony-arm64": "4.63.3", "@rollup/rollup-win32-arm64-msvc": "4.63.3", "@rollup/rollup-win32-ia32-msvc": "4.63.3", "@rollup/rollup-win32-x64-gnu": "4.63.3", "@rollup/rollup-win32-x64-msvc": "4.63.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw=="], + "rolldown": ["rolldown@1.2.13", "", { "dependencies": { "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.13", "@rolldown/binding-android-arm64": "1.2.13", "@rolldown/binding-darwin-arm64": "1.2.13", "@rolldown/binding-darwin-x64": "1.2.13", "@rolldown/binding-freebsd-x64": "1.2.13", "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", "@rolldown/binding-linux-arm64-gnu": "1.2.13", "@rolldown/binding-linux-arm64-musl": "1.2.13", "@rolldown/binding-linux-ppc64-gnu": "1.2.13", "@rolldown/binding-linux-s390x-gnu": "1.2.13", "@rolldown/binding-linux-x64-gnu": "1.2.13", "@rolldown/binding-linux-x64-musl": "1.2.13", "@rolldown/binding-openharmony-arm64": "1.2.13", "@rolldown/binding-win32-arm64-msvc": "1.2.13", "@rolldown/binding-win32-x64-msvc": "1.2.13" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ=="], "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], @@ -540,33 +528,55 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "shiki": ["shiki@4.5.0", "", { "dependencies": { "@shikijs/core": "4.5.0", "@shikijs/engine-javascript": "4.5.0", "@shikijs/engine-oniguruma": "4.5.0", "@shikijs/langs": "4.5.0", "@shikijs/themes": "4.5.0", "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ=="], "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "typescript-api": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="], + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], "uuid": ["uuid@14.0.3", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw=="], - "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@8.3.4", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="], "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 000000000..196bb675b --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,25 @@ +# Changelog fragments + +A PR that changes something users notice adds one file here instead of editing +[CHANGELOG.md](../CHANGELOG.md): every PR appending to the same `## [Unreleased]` lists was the +repo's most frequent merge conflict. Do not edit CHANGELOG.md in a PR. + +- Name the file after the change, lowercase words joined by `-`, ending in `.md` + (`composer-attach-limit.md`). Any unique name works; the release lists fragments by name. +- Write it exactly as the lines would read in CHANGELOG.md: a `### Added`, `### Changed`, + `### Removed`, `### Fixed` or `### Maintenance` heading, then one or more `- ` entries, + wrapped at about 100 columns with two-space continuation lines and no blank line inside an + entry. One file may hold several sections. +- Leave out the PR link and your name: the release adds the link of the PR that merged the file + (to each entry that does not link it already), and a maintainer adds `by @login` for outside + contributors. A link you write yourself is kept. + +```md +### Fixed +- A pasted image no longer disappears when the composer is resized while it uploads. +``` + +`scripts/changelog-fold.test.ts` checks every file here (and that CHANGELOG.md holds no conflict +markers). At release, `bun scripts/changelog-fold.ts X.Y.Z` moves the existing Unreleased notes and +these fragments under the new version and deletes the fragments; the release workflow refuses to +tag while a fragment is left here. diff --git a/changelog.d/agent-list-supported-only.md b/changelog.d/agent-list-supported-only.md new file mode 100644 index 000000000..e713182ca --- /dev/null +++ b/changelog.d/agent-list-supported-only.md @@ -0,0 +1,4 @@ +### Changed +- The agent list in New workspace, New tab and New worktree offers only the agents this app supports + (the Supported agents table of the guide) and that PC has installed, instead of every agent herdr + has a manifest for. Any other program still starts from a pane's shell. diff --git a/changelog.d/agent-picker-unrelated-scroll.md b/changelog.d/agent-picker-unrelated-scroll.md new file mode 100644 index 000000000..a76612c1d --- /dev/null +++ b/changelog.d/agent-picker-unrelated-scroll.md @@ -0,0 +1,7 @@ +### Fixed +- The **agent picker** in the New workspace and New worktree dialogs no longer closes when an + unrelated element scrolls, such as a text field scrolling its own text. It closes when the + window resizes or when something that holds it scrolls. Moving through a long list with the + arrow keys scrolls only the list, so the dialog no longer moves and closes the picker. + ([#1010](https://github.com/devswha/herdr-web-ui/pull/1010), + [#889](https://github.com/devswha/herdr-web-ui/pull/889) by @willowite) diff --git a/changelog.d/agents-pin-menu-drop-cards-quiet.md b/changelog.d/agents-pin-menu-drop-cards-quiet.md new file mode 100644 index 000000000..ddbf439a4 --- /dev/null +++ b/changelog.d/agents-pin-menu-drop-cards-quiet.md @@ -0,0 +1,8 @@ +### Changed +- An agent in the Agents list is pinned from its `⋯` menu or a right-click (Pin agent / Unpin + agent) instead of a star beside it, and pinned agents stand under a Pinned head. Pins work + without a setting, and pins saved before stay. + +### Removed +- Settings → Appearance no longer has Workspace cards, Pin agents or Quiet opened finishes. The + Agents list is always flat, and a finished agent keeps herdr's DONE until herdr shows it. diff --git a/changelog.d/alerts-menu-check.md b/changelog.d/alerts-menu-check.md new file mode 100644 index 000000000..09766f8c4 --- /dev/null +++ b/changelog.d/alerts-menu-check.md @@ -0,0 +1,3 @@ +### Changed +- The header menu's Alerts row shows a check mark while alerts are on, so it reads as the switch it + is instead of a link to Settings. diff --git a/changelog.d/android-keyboard-keeps-pty-size.md b/changelog.d/android-keyboard-keeps-pty-size.md new file mode 100644 index 000000000..06f15c8e4 --- /dev/null +++ b/changelog.d/android-keyboard-keeps-pty-size.md @@ -0,0 +1,3 @@ +### Fixed +- Android Chrome's soft keyboard no longer resizes the shared terminal when it also shrinks the + layout viewport and the large viewport ruler. diff --git a/changelog.d/attach-held-split-teardown.md b/changelog.d/attach-held-split-teardown.md new file mode 100644 index 000000000..ac651813a --- /dev/null +++ b/changelog.d/attach-held-split-teardown.md @@ -0,0 +1,7 @@ +### Fixed +- A pane another web bridge holds no longer unlocks its input for a moment and shows the "held" + notice twice when a busy herdr writes its refusal a little after the attach's teardown. + +### Maintenance +- The pending-input "turn never starts" test waits for the recorder to log the bridge's Enter + before comparing the bytes, instead of racing it. diff --git a/changelog.d/attach-missing-herdr.md b/changelog.d/attach-missing-herdr.md new file mode 100644 index 000000000..ca8c9a3c3 --- /dev/null +++ b/changelog.d/attach-missing-herdr.md @@ -0,0 +1,3 @@ +### Fixed +- A terminal whose herdr the server cannot start (not on the server's PATH, or `HERDR_WEB_HERDR_BIN` + naming no executable) now says so and the server logs it, instead of waiting for input forever. diff --git a/changelog.d/attachment-limit-32-mb.md b/changelog.d/attachment-limit-32-mb.md new file mode 100644 index 000000000..5c868ffe6 --- /dev/null +++ b/changelog.d/attachment-limit-32-mb.md @@ -0,0 +1,6 @@ +### Changed +- A file up to 32 MB can be attached in the composer (it was 8 MB). + +### Fixed +- A file picked in the browser over the attachment limit is refused with the size message + before it is uploaded, rather than ending in a blank 413 the browser cannot explain. diff --git a/changelog.d/auth-shared-failures-cap.md b/changelog.d/auth-shared-failures-cap.md new file mode 100644 index 000000000..39aae5bad --- /dev/null +++ b/changelog.d/auth-shared-failures-cap.md @@ -0,0 +1,2 @@ +### Fixed +- Shared authentication failure bookkeeping stays bounded as forwarded client addresses rotate. diff --git a/changelog.d/browser-checks-wait-for-what-they-read.md b/changelog.d/browser-checks-wait-for-what-they-read.md new file mode 100644 index 000000000..6a18a55ee --- /dev/null +++ b/changelog.d/browser-checks-wait-for-what-they-read.md @@ -0,0 +1,5 @@ +### Maintenance +- Four browser checks no longer fail on a slow run: the terminal input check waits for the + connection before it asks for a liveness probe, the update line and plan-limit checks count a + request once it has been answered or by the page that made it, and the phone transcript check + reads the transcript's style in one step. diff --git a/changelog.d/browser-lane-reruns-a-failed-script.md b/changelog.d/browser-lane-reruns-a-failed-script.md new file mode 100644 index 000000000..7ca37ffa0 --- /dev/null +++ b/changelog.d/browser-lane-reruns-a-failed-script.md @@ -0,0 +1,4 @@ +### Maintenance +- CI's browser lanes run a failed script a second time. One that then passes is reported as flaky + (a warning on the run, its first log kept) instead of failing the job; one that fails twice + still fails it. diff --git a/changelog.d/chat-blank-under-last-message.md b/changelog.d/chat-blank-under-last-message.md new file mode 100644 index 000000000..3bf8c46fb --- /dev/null +++ b/changelog.d/chat-blank-under-last-message.md @@ -0,0 +1,4 @@ +### Fixed +- Chat no longer leaves a growing blank space under the last message, which could stay until the + chat was reopened: the list's brief end padding was never removed on screens where it fell between + pixels. diff --git a/changelog.d/chat-composer-autocorrect.md b/changelog.d/chat-composer-autocorrect.md new file mode 100644 index 000000000..7e0b66540 --- /dev/null +++ b/changelog.d/chat-composer-autocorrect.md @@ -0,0 +1,3 @@ +### Changed +- The chat message box and a queued message's text now take a phone keyboard's autocorrect, + suggestions and sentence capitals. The terminal input line keeps them off. diff --git a/changelog.d/chat-find-menu-placement.md b/changelog.d/chat-find-menu-placement.md new file mode 100644 index 000000000..4b35ee15c --- /dev/null +++ b/changelog.d/chat-find-menu-placement.md @@ -0,0 +1,2 @@ +### Fixed +- Keep Find in messages clear of the three-dot pane menu and the phone's header. diff --git a/changelog.d/chat-pageup-stale-follow.md b/changelog.d/chat-pageup-stale-follow.md new file mode 100644 index 000000000..fbefe6f41 --- /dev/null +++ b/changelog.d/chat-pageup-stale-follow.md @@ -0,0 +1,4 @@ +### Fixed +- PageUp in the chat view no longer snaps back to the latest message when it is pressed right + after the view resized or new output arrived: a follow-the-end scroll still waiting in the + message list is dropped when the reader pages away. diff --git a/changelog.d/chat-read-http-timeout.md b/changelog.d/chat-read-http-timeout.md new file mode 100644 index 000000000..118aa9ab7 --- /dev/null +++ b/changelog.d/chat-read-http-timeout.md @@ -0,0 +1,2 @@ +### Fixed +- Chat and conversation history reads no longer lose their HTTP connection after ten seconds while the bridge is still waiting for valid herdr replies. diff --git a/changelog.d/chat-selection-copy.md b/changelog.d/chat-selection-copy.md new file mode 100644 index 000000000..edcdce75b --- /dev/null +++ b/changelog.d/chat-selection-copy.md @@ -0,0 +1,2 @@ +### Fixed +- Selecting text in a chat reply keeps inline file paths on the clipboard, including code links on touch screens. diff --git a/changelog.d/chat-send-input-box-check.md b/changelog.d/chat-send-input-box-check.md new file mode 100644 index 000000000..a93a279f4 --- /dev/null +++ b/changelog.d/chat-send-input-box-check.md @@ -0,0 +1,8 @@ +### Fixed +- A chat message sent to an OmO pane while it works is no longer refused as "Claude Code's input box + is not empty": the input box under OmO's status rule (`── • Running …`) is read again. + +### Changed +- When a chat message is refused because Claude Code's input box in the terminal may not be empty, + and no draft can be named, the note shows what the box holds and offers **Send anyway**, which + sends that message once into the same Claude terminal. diff --git a/changelog.d/chat-send-keeps-focus.md b/changelog.d/chat-send-keeps-focus.md new file mode 100644 index 000000000..5b5daa8fe --- /dev/null +++ b/changelog.d/chat-send-keeps-focus.md @@ -0,0 +1,4 @@ +### Fixed +- **Send in the chat keeps the keyboard up on a phone.** Pressing it used to take focus from the + message box, which closed the keyboard and moved the button before the tap landed, so the message + was not sent. ([#937](https://github.com/devswha/herdr-web-ui/pull/937) by @lunchyfox) diff --git a/changelog.d/ci-health-after-the-retry.md b/changelog.d/ci-health-after-the-retry.md new file mode 100644 index 000000000..023ca937d --- /dev/null +++ b/changelog.d/ci-health-after-the-retry.md @@ -0,0 +1,6 @@ +### Maintenance +- The SSH password test pulls its disposable PC's image from a second and a third registry when + one refuses the runner for its pull limit, instead of failing on a limit that is not the + test's. +- The prompt dock check waits for the answered question's own card to go, not for any card: the + demo's approval could take its place at once and the wait then ran out. diff --git a/changelog.d/ci-names-the-failing-check.md b/changelog.d/ci-names-the-failing-check.md new file mode 100644 index 000000000..996c7f701 --- /dev/null +++ b/changelog.d/ci-names-the-failing-check.md @@ -0,0 +1,3 @@ +### Maintenance +- Browser CI reports name the failing assertion and script line, and a read-only CLI ranks + failures and flakes from recent workflow attempts. diff --git a/changelog.d/ci-runners-share-check-slots.md b/changelog.d/ci-runners-share-check-slots.md new file mode 100644 index 000000000..1962eddcf --- /dev/null +++ b/changelog.d/ci-runners-share-check-slots.md @@ -0,0 +1,4 @@ +### Maintenance +- The self-hosted CI runners of one PC share `bun run check`'s slots: their Integration and + Browser regression jobs wait for a slot instead of all running at once and failing each other's + timing-bound tests. diff --git a/changelog.d/claude-completed-turns.md b/changelog.d/claude-completed-turns.md new file mode 100644 index 000000000..e9788d2f5 --- /dev/null +++ b/changelog.d/claude-completed-turns.md @@ -0,0 +1,2 @@ +### Fixed +- Keep completed Claude replies in separate chat messages when background notifications or hooks resume the agent without a new user prompt. diff --git a/changelog.d/claude-nocolor-tip-not-a-draft.md b/changelog.d/claude-nocolor-tip-not-a-draft.md new file mode 100644 index 000000000..4059cfb2f --- /dev/null +++ b/changelog.d/claude-nocolor-tip-not-a-draft.md @@ -0,0 +1,4 @@ +### Fixed +- Chat sends to Claude Code started with `NO_COLOR` are no longer refused because its empty-prompt + tip (`Try "…"`) was read as text typed in the terminal, and "Clear it and send" no longer fails + on it. diff --git a/changelog.d/claude-resume-background-session.md b/changelog.d/claude-resume-background-session.md new file mode 100644 index 000000000..8e29a2e39 --- /dev/null +++ b/changelog.d/claude-resume-background-session.md @@ -0,0 +1,3 @@ +### Fixed +- Chat no longer stays empty after `/resume` of a Claude Code session that runs in the background: + it follows the job the command names to that job's own transcript. diff --git a/changelog.d/claude-thinking-updates.md b/changelog.d/claude-thinking-updates.md new file mode 100644 index 000000000..43ef27252 --- /dev/null +++ b/changelog.d/claude-thinking-updates.md @@ -0,0 +1,3 @@ +### Fixed +- Chat shows the progress messages a recent Claude Code prints between its tool calls. Claude Code + records them as thinking, so they were hidden unless Show thinking was on. diff --git a/changelog.d/close-workspace-singular.md b/changelog.d/close-workspace-singular.md new file mode 100644 index 000000000..162a02d4d --- /dev/null +++ b/changelog.d/close-workspace-singular.md @@ -0,0 +1,3 @@ +### Fixed +- The Close workspace confirmation reads "1 pane closes with it, and the agent in it stops." for a + workspace with one pane, and counts one worktree workspace in the singular too. diff --git a/changelog.d/closed-workspace-leaves-at-once.md b/changelog.d/closed-workspace-leaves-at-once.md new file mode 100644 index 000000000..3a97f50a8 --- /dev/null +++ b/changelog.d/closed-workspace-leaves-at-once.md @@ -0,0 +1,4 @@ +### Fixed +- A closed workspace leaves the sidebar as soon as herdr closes it, about half a second sooner: + its panes send no exit, so browsers used to learn of it only after the next roster refresh. + Removing a worktree does the same. ([#1017](https://github.com/devswha/herdr-web-ui/pull/1017), [#944](https://github.com/devswha/herdr-web-ui/pull/944) by @todor-a) diff --git a/changelog.d/codex-binding-short-answers-and-resume-picker.md b/changelog.d/codex-binding-short-answers-and-resume-picker.md new file mode 100644 index 000000000..8fdc99373 --- /dev/null +++ b/changelog.d/codex-binding-short-answers-and-resume-picker.md @@ -0,0 +1,3 @@ +### Fixed +- Codex Chat recognizes short first answers and prompts preceded by model, command, or + working-directory notices while retaining exact prompt and conversation ownership checks. diff --git a/changelog.d/codex-row-title-without-folder.md b/changelog.d/codex-row-title-without-folder.md new file mode 100644 index 000000000..abc170759 --- /dev/null +++ b/changelog.d/codex-row-title-without-folder.md @@ -0,0 +1,4 @@ +### Changed +- A Codex pane's row and header show its thread's title alone. Codex adds the project folder + after it ("Respond to greeting | my-worktree..."), which the row already says underneath. + Alerts keep the whole title: they are read away from the row. diff --git a/changelog.d/composer-no-room.md b/changelog.d/composer-no-room.md new file mode 100644 index 000000000..ccc1f1056 --- /dev/null +++ b/changelog.d/composer-no-room.md @@ -0,0 +1,4 @@ +### Fixed +- A file whose `@path` has no room in a message near 20,000 characters says so: its tile reads + "No room" and the status line says how many characters to remove. Removing that file lets the + files picked after it in. Within 200 characters of the limit the status line counts what is left. diff --git a/changelog.d/composer-send-progress.md b/changelog.d/composer-send-progress.md new file mode 100644 index 000000000..81f9f5b8e --- /dev/null +++ b/changelog.d/composer-send-progress.md @@ -0,0 +1,4 @@ +### Fixed + +- Chat Send now shows a progress ring while the pane acknowledges the message, keeping the draft visible until it is accepted. +- The Send action keeps its accessible name while busy; its description reports that the message is sending. diff --git a/changelog.d/composer-stable-while-working.md b/changelog.d/composer-stable-while-working.md new file mode 100644 index 000000000..ef6eae27d --- /dev/null +++ b/changelog.d/composer-stable-while-working.md @@ -0,0 +1,2 @@ +### Fixed +- Keep a typed chat draft stable when agent prompt suggestions and streaming status metadata update. diff --git a/changelog.d/composer-strip-branch.md b/changelog.d/composer-strip-branch.md new file mode 100644 index 000000000..86e964218 --- /dev/null +++ b/changelog.d/composer-strip-branch.md @@ -0,0 +1,4 @@ +### Added +- The strip under the message box names the workspace's branch at its right end, beside the + folder, for a repository that has a worktree open (the branches the sidebar already shows). +- That strip marks a linked worktree as one, beside its folder's name. diff --git a/changelog.d/composer-strip-off-on-phones.md b/changelog.d/composer-strip-off-on-phones.md new file mode 100644 index 000000000..a1369fe2c --- /dev/null +++ b/changelog.d/composer-strip-off-on-phones.md @@ -0,0 +1,3 @@ +### Changed +- A phone no longer draws the folder and branch strip under the message box, which took room from + the conversation; a desktop still shows it. diff --git a/changelog.d/composer-strip-repo-and-setting.md b/changelog.d/composer-strip-repo-and-setting.md new file mode 100644 index 000000000..75bfbca77 --- /dev/null +++ b/changelog.d/composer-strip-repo-and-setting.md @@ -0,0 +1,3 @@ +### Changed +- In a linked worktree the strip under the message box names the repository instead of the + checkout folder, which only repeated the branch at its other end. diff --git a/changelog.d/desktop-password-prompt-typing.md b/changelog.d/desktop-password-prompt-typing.md new file mode 100644 index 000000000..00fe60b85 --- /dev/null +++ b/changelog.d/desktop-password-prompt-typing.md @@ -0,0 +1,5 @@ +### Fixed +- On a desktop, a password prompt in the terminal no longer blocks typing into the grid: the + password goes straight to the program, as in any terminal, while the masked input stays as an + option for a phone. Offline, or while input is held for review, a password keystroke is refused + with a note instead of being kept in a draft. diff --git a/changelog.d/dev-proxy-origin.md b/changelog.d/dev-proxy-origin.md new file mode 100644 index 000000000..a8d110fd0 --- /dev/null +++ b/changelog.d/dev-proxy-origin.md @@ -0,0 +1,3 @@ +### Maintenance +- `bun run dev` no longer answers every state-changing request with 403 `invalid_origin`: Vite's + `/api` proxy keeps the browser's Host, which the server's same-origin check compares with Origin. diff --git a/changelog.d/docs-t3-features.md b/changelog.d/docs-t3-features.md new file mode 100644 index 000000000..460d89e79 --- /dev/null +++ b/changelog.d/docs-t3-features.md @@ -0,0 +1,5 @@ +### Maintenance +- The user guide covers chat Find (Mod+F), the image viewer, skill chips, the project favicon, the + composer's folder strip, Files → Changes, the large-file source view, the + palette's Settings pages and folder browsing, and the new Appearance settings; the agent guides + point to their code and to `GET /api/workspace/diff/files` and `/diff/file`. diff --git a/changelog.d/drawer-closes-on-view-switch.md b/changelog.d/drawer-closes-on-view-switch.md new file mode 100644 index 000000000..2b0afd47a --- /dev/null +++ b/changelog.d/drawer-closes-on-view-switch.md @@ -0,0 +1,3 @@ +### Fixed +- On a phone, switching between Chat and Terminal from the header closes the workspace list, so it + no longer stays open over the view just picked. diff --git a/changelog.d/drawer-escape-closes.md b/changelog.d/drawer-escape-closes.md new file mode 100644 index 000000000..4c756cc49 --- /dev/null +++ b/changelog.d/drawer-escape-closes.md @@ -0,0 +1,3 @@ +### Fixed +- On a phone, Escape now closes the open workspace list instead of typing `^[` into the terminal + behind it or doing nothing. diff --git a/changelog.d/drop-turkish.md b/changelog.d/drop-turkish.md new file mode 100644 index 000000000..2c24f248a --- /dev/null +++ b/changelog.d/drop-turkish.md @@ -0,0 +1,3 @@ +### Removed +- The Turkish UI language and the Turkish README. A browser set to Turkish now shows English, and + a device that chose Turkish in Settings goes back to following the browser. diff --git a/changelog.d/droplet-close-button.md b/changelog.d/droplet-close-button.md new file mode 100644 index 000000000..a0cf6bf7d --- /dev/null +++ b/changelog.d/droplet-close-button.md @@ -0,0 +1,2 @@ +### Added +- The in-app alert has a close button, so it can be put away without a flick or a wait. diff --git a/changelog.d/first-chat-of-a-started-agent.md b/changelog.d/first-chat-of-a-started-agent.md new file mode 100644 index 000000000..214822f50 --- /dev/null +++ b/changelog.d/first-chat-of-a-started-agent.md @@ -0,0 +1,10 @@ +### Fixed +- An agent started from New workspace, New tab or New worktree opens its chat on the greeting over + the message box. Codex, Claude Code, OpenCode, gjc and Grok write no session before the first + message, so their chat said "Conversation unavailable" until then. A worktree's agent also opens + in the same lens a new workspace's does. +- The message box of a chat not begun names the model, read from the agent's own start screen: + Claude Code's model, and the model and reasoning effort of Codex and gjc. It was blank until the + first message wrote a transcript. +- An agent started from the app reads READY until its first turn. Codex drawing its start screen + read as working and then DONE, on a pane nobody had asked anything of. diff --git a/changelog.d/gjc-conversation-single-flight.md b/changelog.d/gjc-conversation-single-flight.md new file mode 100644 index 000000000..7c36817f4 --- /dev/null +++ b/changelog.d/gjc-conversation-single-flight.md @@ -0,0 +1,3 @@ +### Fixed +- Several devices or polls reading one GJC chat at the same moment now share a single lookup + instead of each starting the GJC CLI (about 0.7 s apiece). diff --git a/changelog.d/gjc-native-turn-status.md b/changelog.d/gjc-native-turn-status.md new file mode 100644 index 000000000..1e5db15a7 --- /dev/null +++ b/changelog.d/gjc-native-turn-status.md @@ -0,0 +1,7 @@ +### Fixed +- A Gajae Code pane's status now comes from gjc's own session transcript instead of what herdr + reports for the pane, which could keep a finished turn at RUN for work a provider child was + still doing — and a turn gjc ran while the child was quiet never read RUN or sent a done + alert. A turn that ended in an error or was aborted reads READY rather than DONE, a + background job's result that wakes the agent reads RUN, and a pane whose transcript cannot + be told keeps herdr's status. diff --git a/changelog.d/gjc-question-ids-stable.md b/changelog.d/gjc-question-ids-stable.md new file mode 100644 index 000000000..baad2883b --- /dev/null +++ b/changelog.d/gjc-question-ids-stable.md @@ -0,0 +1,6 @@ +### Fixed +- Gajae Code question cards no longer refuse an answer as stale when the transcript or status + above the question dialog redraws. The dialog's question, details and choices still have to match + before the answer is confirmed. + ([#1020](https://github.com/devswha/herdr-web-ui/pull/1020), + [#997](https://github.com/devswha/herdr-web-ui/pull/997) by @yuseongkim-inbl) diff --git a/changelog.d/gjc-sdk-session-binding.md b/changelog.d/gjc-sdk-session-binding.md new file mode 100644 index 000000000..dba66642d --- /dev/null +++ b/changelog.d/gjc-sdk-session-binding.md @@ -0,0 +1,6 @@ +### Fixed +- GJC chat identifies its session through GJC's SDK broker instead of matching terminal + answers or session titles. Copied output from another session can no longer select that + session's conversation. This requires GJC 0.18.9 or later for its broker-bound SDK + session CLI; an older GJC, or an unavailable or ambiguous SDK identity, uses Terminal instead. + ([#912](https://github.com/devswha/herdr-web-ui/pull/912)) diff --git a/changelog.d/held-input-backspace-and-line-breaks.md b/changelog.d/held-input-backspace-and-line-breaks.md new file mode 100644 index 000000000..d846b1094 --- /dev/null +++ b/changelog.d/held-input-backspace-and-line-breaks.md @@ -0,0 +1,7 @@ +### Fixed +- Input held while the terminal is not ready now follows the Backspace key, so text erased with it + is not sent, and erasing all of it clears the notice. An Enter between typed text or a multi-line + paste keeps its text with a space where the line break was, and the held-input notice says the + break was left out instead of dropping the paste in silence; Send still never presses Enter. A + paste with other control characters is told as left out, the notice no longer blames length for + it, and neither an Enter with nothing held nor a Shift+Enter leaves a notice. diff --git a/changelog.d/herdr-rpc-seam.md b/changelog.d/herdr-rpc-seam.md new file mode 100644 index 000000000..41e60d616 --- /dev/null +++ b/changelog.d/herdr-rpc-seam.md @@ -0,0 +1,5 @@ +### Maintenance +- herdr RPCs are typed per method (`HerdrMethods`): server code calls `paneProcessInfo`, + `agentGet` and the other wrappers in `server/herdr/client.ts`, and `server/conventions.test.ts` + keeps it off the untyped `herdrRpc`, so asking `pane.process_info` by `target` no longer compiles + without an explicit result type. diff --git a/changelog.d/input-line-overlays-grid.md b/changelog.d/input-line-overlays-grid.md new file mode 100644 index 000000000..16dfeee3c --- /dev/null +++ b/changelog.d/input-line-overlays-grid.md @@ -0,0 +1,5 @@ +### Fixed +- The terminal's input line no longer resizes the pane while it grows to more rows, shows a note + or collapses after Send: it now floats over the terminal's last rows, and so does the masked + password input, so the pane keeps its size for every device on it. In a short pane both stop at + the terminal's top and scroll, and switching lenses keeps what was typed in the masked input. diff --git a/changelog.d/install-lock-stale-recovery.md b/changelog.d/install-lock-stale-recovery.md new file mode 100644 index 000000000..cf7d21d41 --- /dev/null +++ b/changelog.d/install-lock-stale-recovery.md @@ -0,0 +1,4 @@ +### Fixed +- A bridge install that was cut off mid-upload no longer blocks later updates with "Another + installation is in progress": a stale `install.lock` is reclaimed, and the upload is bounded + ([#895](https://github.com/devswha/herdr-web-ui/pull/895) by @suho-han). diff --git a/changelog.d/install-ps1-gh-job-object.md b/changelog.d/install-ps1-gh-job-object.md new file mode 100644 index 000000000..6283a1bd7 --- /dev/null +++ b/changelog.d/install-ps1-gh-job-object.md @@ -0,0 +1,4 @@ +### Fixed +- The Windows installer assigns `gh` to a job before allowing a shim to start a child that keeps + its output open: the job ends with the call, and its output pipes are closed. If Windows refuses + job assignment, the existing launcher-based cleanup remains, without guaranteeing orphan cleanup. diff --git a/changelog.d/ipad-hangul-composition.md b/changelog.d/ipad-hangul-composition.md new file mode 100644 index 000000000..3ac662dd2 --- /dev/null +++ b/changelog.d/ipad-hangul-composition.md @@ -0,0 +1,7 @@ +### Fixed +- Korean typed on an iPad with a hardware keyboard (Magic Keyboard) reaches the terminal as whole + syllables again. iPadOS Safari sent each jamo as its own key, so 한 arrived as ㅎㅏㄴ; the + terminal now lets the iPad compose the syllable and sends it once, before the next key. +- The syllable being typed in the terminal is drawn at the size of the ones already typed. On Apple + devices the terminal enlarges Hangul to fill its two cells, but the box that shows the pending + syllable kept the plain size, so the last syllable looked smaller until it was committed. diff --git a/changelog.d/keep-grid-unfocused-desktop.md b/changelog.d/keep-grid-unfocused-desktop.md new file mode 100644 index 000000000..33920683a --- /dev/null +++ b/changelog.d/keep-grid-unfocused-desktop.md @@ -0,0 +1,3 @@ +### Fixed +- A visible desktop terminal keeps its grid when its window loses focus, so returning to + Chat on a phone restores the desktop width without requiring another click or refocus. diff --git a/changelog.d/keybar-live-while-composing.md b/changelog.d/keybar-live-while-composing.md new file mode 100644 index 000000000..40b80fb7b --- /dev/null +++ b/changelog.d/keybar-live-while-composing.md @@ -0,0 +1,4 @@ +### Fixed +- On a phone, the key bar no longer goes grey while a word is still being typed in the input line: + Esc, ^C, the arrows and Ctrl/Alt/Shift work mid-word with Gboard or a Korean or Japanese keyboard. + Only the switch to direct typing waits until the word is finished. diff --git a/changelog.d/keybar-off-the-pane-edge.md b/changelog.d/keybar-off-the-pane-edge.md new file mode 100644 index 000000000..6669ab43d --- /dev/null +++ b/changelog.d/keybar-off-the-pane-edge.md @@ -0,0 +1,4 @@ +### Fixed +- The terminal's key bar no longer sits against the pane's left edge, and on an iPad or phone only + the bar at the bottom of the screen keeps room for the home indicator: a pane stacked over + another one no longer has empty space under its keys. diff --git a/changelog.d/late-joiner-stall-by-ack-progress.md b/changelog.d/late-joiner-stall-by-ack-progress.md new file mode 100644 index 000000000..3792f02e5 --- /dev/null +++ b/changelog.d/late-joiner-stall-by-ack-progress.md @@ -0,0 +1,8 @@ +### Fixed +- A phone on a weak mobile link that opens a busy pane another tab already shows is no longer cut + off with "Terminal output stopped because this device could not keep up." while it is still + receiving the pane's recent output, and it no longer freezes that pane's output in the other tab + meanwhile. A device is now stopped when it has acknowledged nothing for 2 seconds (longer while + a large chunk of output is still on its way to it), or has held the pane's output paused for 10 + seconds. A quiet split pane is no longer frozen or cut off because the pane next to it is busy. + A device joining a busy pane now gets its last 128 KiB of output instead of 256 KiB. diff --git a/changelog.d/lazy-chat-timeline.md b/changelog.d/lazy-chat-timeline.md new file mode 100644 index 000000000..3df323154 --- /dev/null +++ b/changelog.d/lazy-chat-timeline.md @@ -0,0 +1,3 @@ +### Changed +- The app's first script is about 42 KB smaller (gzip): the chat timeline and its list library + load as their own file beside it, instead of inside it. diff --git a/changelog.d/macos-local-test-runs.md b/changelog.d/macos-local-test-runs.md new file mode 100644 index 000000000..7c3d4eeab --- /dev/null +++ b/changelog.d/macos-local-test-runs.md @@ -0,0 +1,6 @@ +### Maintenance +- The browser scripts and `scripts/check.test.ts` run on a Mac as they are: a script without + `CHROME_PATH` falls back to the lockfile's Playwright Chromium where Google Chrome's Linux path + does not exist, the check tests make their scratch directories under `/tmp` so a socket inside + stays within the path limit, and `scripts/output-browser-qa.ts` opens its page in English + whatever the PC's language. diff --git a/changelog.d/math-check-late-request.md b/changelog.d/math-check-late-request.md new file mode 100644 index 000000000..8f6abceaa --- /dev/null +++ b/changelog.d/math-check-late-request.md @@ -0,0 +1,3 @@ +### Maintenance +- The math browser check answers a conversation request that reaches its server while the browser + closes, instead of failing after every check has passed. diff --git a/changelog.d/mirror-ctrl-enter.md b/changelog.d/mirror-ctrl-enter.md new file mode 100644 index 000000000..fd754a0bf --- /dev/null +++ b/changelog.d/mirror-ctrl-enter.md @@ -0,0 +1,4 @@ +### Fixed +- Ctrl+Enter in a mirrored terminal (Windows, or a PC without Node for the terminal sidecar) + reaches Claude Code as its "send now" again instead of a plain Enter, and `bun run test:ui` + checks it there instead of stopping at that check. diff --git a/changelog.d/mirror-secret-authority.md b/changelog.d/mirror-secret-authority.md new file mode 100644 index 000000000..f671a185e --- /dev/null +++ b/changelog.d/mirror-secret-authority.md @@ -0,0 +1,6 @@ +### Fixed +- A password sent to a mirrored terminal is no longer submitted after the sender leaves the pane + or switches to view-only mode; the typed characters are erased before reporting the refusal. +- Mirrored terminals refuse passwords containing non-ASCII characters before typing anything, + so cancellation can erase every accepted character on Windows as well. Attached terminals + continue to accept Unicode passwords. diff --git a/changelog.d/no-pull-to-refresh.md b/changelog.d/no-pull-to-refresh.md new file mode 100644 index 000000000..a716763bb --- /dev/null +++ b/changelog.d/no-pull-to-refresh.md @@ -0,0 +1,3 @@ +### Fixed +- Dragging down in a chat too short to scroll no longer reloads the app on Android Chrome before + 144; the page itself no longer starts pull-to-refresh. diff --git a/changelog.d/notification-tap-target.md b/changelog.d/notification-tap-target.md new file mode 100644 index 000000000..fb63687ee --- /dev/null +++ b/changelog.d/notification-tap-target.md @@ -0,0 +1,8 @@ +### Fixed +- Tapping an alert no longer brings forward a Markdown reader, a raw file tab or another shipped + file the browser has open: the app window takes the pane, or the app opens on it. +- An alert, a reload or a new window naming a PC removed since now opens this PC with "That + link's PC is not set up here" instead of an empty pane. An alert for a PC set up elsewhere since + the tab last looked waits for a fresh list of PCs instead of being dropped. +- An alert for a pane that has closed (a terminal ended) opens the workspace it was in and says so + in the header, as a link to it does, also when the phone delivers the tap twice. diff --git a/changelog.d/omo-pending-question-card.md b/changelog.d/omo-pending-question-card.md new file mode 100644 index 000000000..293cb0b55 --- /dev/null +++ b/changelog.d/omo-pending-question-card.md @@ -0,0 +1,5 @@ +### Fixed +- An OmO question asked without waiting for its answer shows as its question card in the chat + again, instead of a "Waiting for input" card with the terminal's text and Enter/Esc. OmO 5.1.30 + draws its running tasks under the input box, and on a narrow pane wraps the question's hint over + two lines; either made the question unreadable. Waiting forms with tasks under the box are read too. diff --git a/changelog.d/omo-runtime-session-binding.md b/changelog.d/omo-runtime-session-binding.md new file mode 100644 index 000000000..0ae80741a --- /dev/null +++ b/changelog.d/omo-runtime-session-binding.md @@ -0,0 +1,6 @@ +### Fixed +- An OmO session started by a launcher that does not look like OmO (its own `OMO_CODING_AGENT_DIR` + store, an arbitrary command name) now binds its chat only to the native session its live process + verifiably holds in its own store. A newer file in another pane's store no longer takes its + place, conflicting holds read as no session instead of guessing, and the chat follows the held + session through /new and /resume. diff --git a/changelog.d/out-of-use-watch.md b/changelog.d/out-of-use-watch.md new file mode 100644 index 000000000..0c2a5c072 --- /dev/null +++ b/changelog.d/out-of-use-watch.md @@ -0,0 +1,4 @@ +### Fixed +- A terminal opened, reloaded, or reconnected in a background window watches its pane without + acquiring an attach that could change the pane's size. Returning to the window attaches normally; + queued messages, mirrored panes, embedded pages, and older bridges keep their existing behavior. diff --git a/changelog.d/pane-paging-demo-turn-flake.md b/changelog.d/pane-paging-demo-turn-flake.md new file mode 100644 index 000000000..dac9aba64 --- /dev/null +++ b/changelog.d/pane-paging-demo-turn-flake.md @@ -0,0 +1,4 @@ +### Maintenance +- The pane paging browser check waits for the demo's opening turn to end before it measures scroll + positions: that turn ends 4.5 s after load and changes the transcript's height, which broke the + check on a loaded runner. diff --git a/changelog.d/pending-held-copy-cross-tab.md b/changelog.d/pending-held-copy-cross-tab.md new file mode 100644 index 000000000..ca902398b --- /dev/null +++ b/changelog.d/pending-held-copy-cross-tab.md @@ -0,0 +1,4 @@ +### Fixed +- Sending a held message from two browser tabs no longer submits it twice. Its unconfirmed mark + survives other tabs refreshing or disconnecting, and unreadable storage refuses a resend. + After a confirmed no-input refusal, Send now starts a new attempt instead of replaying the refusal. diff --git a/changelog.d/pending-not-delivered-after-leaving-page.md b/changelog.d/pending-not-delivered-after-leaving-page.md new file mode 100644 index 000000000..008f6b692 --- /dev/null +++ b/changelog.d/pending-not-delivered-after-leaving-page.md @@ -0,0 +1,4 @@ +### Fixed +- A chat message queued for the next turn is no longer delivered after the tab has gone to another + address: a page the browser kept for Back still held its connection, so the message went out + while the page shown said it was not confirmed. Leaving a page now ends its connection. diff --git a/changelog.d/pending-process-identity.md b/changelog.d/pending-process-identity.md new file mode 100644 index 000000000..f5f21e5a3 --- /dev/null +++ b/changelog.d/pending-process-identity.md @@ -0,0 +1,3 @@ +### Fixed +- Queued chat messages are held for review when another agent process replaces their target in + the same terminal, including agents without a native session reference. diff --git a/changelog.d/phone-input-reclaims-terminal-size.md b/changelog.d/phone-input-reclaims-terminal-size.md new file mode 100644 index 000000000..5d80ef98f --- /dev/null +++ b/changelog.d/phone-input-reclaims-terminal-size.md @@ -0,0 +1,4 @@ +### Fixed +- A phone showing a pane's terminal no longer types into a grid another device sized: a tap into + the input line, its Send or Enter, and a key-bar tap take the pane back to the phone's size + before anything is sent, as a tap on the grid already did. diff --git a/changelog.d/phone-keyboard-keeps-pty-size.md b/changelog.d/phone-keyboard-keeps-pty-size.md new file mode 100644 index 000000000..740a6d8df --- /dev/null +++ b/changelog.d/phone-keyboard-keeps-pty-size.md @@ -0,0 +1,4 @@ +### Fixed +- On a phone, opening or closing the soft keyboard no longer resizes the shared terminal: the grid + keeps its size and the view pans so the prompt stays above the keyboard, and every other viewer + of the pane, herdr's own window included, keeps its layout. A rotation or a pinch still refits. diff --git a/changelog.d/push-retired-endpoint-restart.md b/changelog.d/push-retired-endpoint-restart.md new file mode 100644 index 000000000..12a785120 --- /dev/null +++ b/changelog.d/push-retired-endpoint-restart.md @@ -0,0 +1,3 @@ +### Fixed +- A retired push subscription is still refused after the server restarts, so Turn alerts on again + makes a new one instead of registering the dead endpoint. diff --git a/changelog.d/push-retired-endpoint.md b/changelog.d/push-retired-endpoint.md new file mode 100644 index 000000000..73f997347 --- /dev/null +++ b/changelog.d/push-retired-endpoint.md @@ -0,0 +1,3 @@ +### Fixed +- Turn alerts on again now makes a new push subscription when the push service retired the old + one, instead of registering the dead endpoint again; Settings says the subscription was retired. diff --git a/changelog.d/queued-pane-navigation.md b/changelog.d/queued-pane-navigation.md new file mode 100644 index 000000000..438ced695 --- /dev/null +++ b/changelog.d/queued-pane-navigation.md @@ -0,0 +1,4 @@ +### Fixed +- Chat messages queued for the next turn keep their original pane connection across tab and + workspace switches and deliver once when that pane is ready. Reloads and lost connections still + require manual recovery. diff --git a/changelog.d/recover-hidden-output-stall.md b/changelog.d/recover-hidden-output-stall.md new file mode 100644 index 000000000..4225a619c --- /dev/null +++ b/changelog.d/recover-hidden-output-stall.md @@ -0,0 +1,5 @@ +### Fixed +- A phone put away while its pane stayed attached (a queued message, a mirrored pane) no longer + comes back to a dead "Terminal output stopped" notice: when the page is in use again, the pane + attaches again by itself, once. A stop while the page stays in use still waits for you, and the + notice's Reconnect button now attaches again in place instead of reloading the page. diff --git a/changelog.d/relay-stall-threshold.md b/changelog.d/relay-stall-threshold.md new file mode 100644 index 000000000..699ae2220 --- /dev/null +++ b/changelog.d/relay-stall-threshold.md @@ -0,0 +1,3 @@ +### Fixed +- A pane on another PC no longer stops after a short network stall on a phone: the relay now stops + output only when 256 KiB stays queued for 2 s, the same rule as this PC, instead of on any queued byte. diff --git a/changelog.d/release-grid-hidden-clients.md b/changelog.d/release-grid-hidden-clients.md new file mode 100644 index 000000000..1687952db --- /dev/null +++ b/changelog.d/release-grid-hidden-clients.md @@ -0,0 +1,3 @@ +### Fixed +- A terminal returning from a phone's Chat view gives its grid back only to a device still + showing that terminal, never to a hidden pane or a tab left in the background. diff --git a/changelog.d/release-grid-on-chat-return.md b/changelog.d/release-grid-on-chat-return.md new file mode 100644 index 000000000..8a2853c0d --- /dev/null +++ b/changelog.d/release-grid-on-chat-return.md @@ -0,0 +1,3 @@ +### Fixed +- A phone that goes back from the terminal to Chat no longer leaves a desktop showing the same pane + at the phone's width: the terminal goes back to the desktop's size at once. diff --git a/changelog.d/remove-terminal-scrollbar.md b/changelog.d/remove-terminal-scrollbar.md new file mode 100644 index 000000000..6e6fb61b3 --- /dev/null +++ b/changelog.d/remove-terminal-scrollbar.md @@ -0,0 +1,4 @@ +### Removed +- The terminal no longer draws a history scrollbar beside the grid, on desktop or phone, and the + grid takes the column it reserved. Wheel, touch drag, Mod+Shift+PageUp/PageDown and Find still + scroll herdr's history. diff --git a/changelog.d/render-error-boundaries.md b/changelog.d/render-error-boundaries.md new file mode 100644 index 000000000..73897d39a --- /dev/null +++ b/changelog.d/render-error-boundaries.md @@ -0,0 +1,4 @@ +### Fixed +- A render error no longer leaves a blank page: the app shows what happened with Reload app and + Copy details, which an installed home-screen app needs since it has no reload button. An error in + the sidebar, a pane or Settings now stays there with its own notice, and the rest keeps working. diff --git a/changelog.d/repo-diff-windowed-plain-rows.md b/changelog.d/repo-diff-windowed-plain-rows.md new file mode 100644 index 000000000..e537ecf81 --- /dev/null +++ b/changelog.d/repo-diff-windowed-plain-rows.md @@ -0,0 +1,4 @@ +### Fixed +- A long diff drawn without colors (too long to highlight, or highlighting off) no longer redraws + every one of its lines on each scroll: it builds only the rows in view, as long files already + do, so each scroll step of a 10 000-line diff blocks the page about half as long. diff --git a/changelog.d/return-newline.md b/changelog.d/return-newline.md new file mode 100644 index 000000000..2543f6f61 --- /dev/null +++ b/changelog.d/return-newline.md @@ -0,0 +1,2 @@ +### Changed +- On touch screens, Settings → Chat → Composer labels the existing Return choice as "Return inserts a new line" and explains sending with Send or a hardware keyboard. The default still sends with Return. diff --git a/changelog.d/settings-history-entries.md b/changelog.d/settings-history-entries.md new file mode 100644 index 000000000..7c38489ba --- /dev/null +++ b/changelog.d/settings-history-entries.md @@ -0,0 +1,5 @@ +### Fixed +- Back no longer reopens a closed Settings when another history entry (a typed `#hash`, another + script's) was added while it was open: Back steps over the Settings entries left beneath it. +- A reload on a file preview opened over Settings (a link naming both) now closes Settings and + keeps the file open. diff --git a/changelog.d/sheet-reopened-during-step-back.md b/changelog.d/sheet-reopened-during-step-back.md new file mode 100644 index 000000000..6f7cd764c --- /dev/null +++ b/changelog.d/sheet-reopened-during-step-back.md @@ -0,0 +1,8 @@ +### Fixed +- On a phone, a sheet opened right after another one closed (before the browser had stepped back + off the closed sheet's history entry) no longer closes at once. + +### Maintenance +- `scripts/repo-diff-browser-qa.ts` waits for the diff's worker colors before it selects and + copies rows: the colored redraw dropped a selection made on the plain rows, so the copy came + back empty. diff --git a/changelog.d/shortcuts-new-tab-split.md b/changelog.d/shortcuts-new-tab-split.md new file mode 100644 index 000000000..ef0c7ec11 --- /dev/null +++ b/changelog.d/shortcuts-new-tab-split.md @@ -0,0 +1,4 @@ +### Added +- **New tab**, **Split pane right** and **Split pane down** can take a Mod+Shift shortcut in + **Settings → Shortcuts**. They have no default key, and the command palette shows the chosen one. + ([#940](https://github.com/devswha/herdr-web-ui/pull/940) by @sergiorademacher) diff --git a/changelog.d/split-drop-on-redrawn-node.md b/changelog.d/split-drop-on-redrawn-node.md new file mode 100644 index 000000000..6975ab080 --- /dev/null +++ b/changelog.d/split-drop-on-redrawn-node.md @@ -0,0 +1,3 @@ +### Fixed +- A pane dragged from the sidebar onto the pane area opens in a half even when the terminal or + chat under the pointer redraws just before the drop; Chrome refused that drop before. diff --git a/changelog.d/stale-asset-404-and-sw-cache.md b/changelog.d/stale-asset-404-and-sw-cache.md new file mode 100644 index 000000000..6522c2cb3 --- /dev/null +++ b/changelog.d/stale-asset-404-and-sw-cache.md @@ -0,0 +1,7 @@ +### Fixed +- A tab left open across an update no longer stores the app's page under a script's address: + a file the server no longer has answers 404, the offline cache is kept per build instead of + growing with every release (typefaces and icons stay cached across updates, and an update + keeps the files the new page starts with and the new build's math and diagram files the old + worker had cached), and math or a diagram that failed to load, including a single diagram + type, brings up a "Reload app" line at once, in a Markdown file opened in its own tab too. diff --git a/changelog.d/stop-before-next-message.md b/changelog.d/stop-before-next-message.md new file mode 100644 index 000000000..2c44fea06 --- /dev/null +++ b/changelog.d/stop-before-next-message.md @@ -0,0 +1,3 @@ +### Fixed +- A message sent right after Stop in the chat composer no longer reaches the agent before the + Stop on a busy PC, and typing in the terminal keeps its order. diff --git a/changelog.d/symlinked-skills.md b/changelog.d/symlinked-skills.md new file mode 100644 index 000000000..fb4851e3b --- /dev/null +++ b/changelog.d/symlinked-skills.md @@ -0,0 +1,2 @@ +### Fixed +- The chat command picker now includes symlinked skills and custom commands, skipping broken links and command directory cycles. diff --git a/changelog.d/t3code-ports-and-chat-rendering.md b/changelog.d/t3code-ports-and-chat-rendering.md new file mode 100644 index 000000000..d3c9f72b1 --- /dev/null +++ b/changelog.d/t3code-ports-and-chat-rendering.md @@ -0,0 +1,113 @@ +### Changed +- App, site and demo builds run Vite 8 on Bun rather than the first Node on PATH, + while type checking uses TypeScript 7. The terminal sidecar still supports Node 18, + and the client keeps its previous browser syntax targets. + ([#830](https://github.com/devswha/herdr-web-ui/pull/830) by @todor-a) +- The browser client uses React 19.3, with native inert handling for offline + workspaces and nullable refs for the settings and tab controls. +- Chat turn times now tell yesterday and older turns apart ("yesterday at 04:45 PM", "9/23 02:30 + PM") and show the full date and time on hover or focus. +- Chat glides to new output while an agent works and you follow the end, instead of snapping to it; + reduced motion keeps the instant scroll. +- A Markdown image on a line of its own holds a 16:9 slot until it decodes and keeps it with an + "Image unavailable" note if it cannot, so a document moves at most once per picture; chat + thumbnails keep their box through loading and failure the same way. +- A work block opens and folds at its measured height: the rows slide out from under the header and + back, a row opened inside it grows it at the same pace, and the reader's scroll position holds + through both; reduced motion snaps it. +- The file browser shows folders relative to the pane's own directory (`herdr/src/components`) + instead of from `~/`; folders outside it keep the `~/` form. +- Keep the selected `/` command or `@` file completion highlighted in the composer when matching + results refresh for the same query. +- The phone keyboard viewport resyncs when the page becomes visible or is restored. +- Composer attachments with an empty or generic browser MIME type infer their image preview and + upload type from a PNG, JPEG, GIF or WebP filename extension. +- Under the chat lens, connection and input notices (another app holding the pane, a failed output + stream, input held for the terminal, a copy note) stand as rows over the input card instead of + floating top-right, each dismissible with an X; a dismissed notice returns when it changes or + recurs. A notice holding input typed while disconnected cannot be dismissed, so Send and Discard + stay reachable. +- **Files → Changes** lists the changed files under their folders: a folder sits where its first + file was listed with its file count, a lone chain of folders is one row, a tap folds a folder, and + a button beside Refresh folds or opens them all. +- Reopening a diff or a long file is faster: the syntax-highlight workers are shared across the app + and stay warm for 30 s after the last viewer closes, and a theme switch no longer restarts them. +- Workspace rows in the sidebar show the project's own favicon, when its folder has one, instead of + the plain folder glyph. +- Chat images open in a full-screen viewer with the message's other images a step away, + pointer-anchored zoom and pan, and Copy image and Download actions, instead of a new tab. +- Copying a chat selection keeps Markdown lists, code and image links, with sanitized rich HTML for + pasting into documents; code-only selections stay plain code. +- A slim strip tucked under the chat composer's card, as t3code's, can name the pane's working + folder and branch. +- A long user message folds to twelve lines behind **Show full message**, opens again with **Show + less** or when Find lands on it, and keeps that choice while the chat scrolls. +- A work row now says what the agent said the call is for (OmO's eval summary, the description + Claude gives a Bash command or a subagent) instead of the tool id or the raw command, which stays + in the opened row and its tooltip. Opening a tool's whole output keeps its work row in place. + +### Added +- The chat box says when a draft hits the 20 000-character cap, and how many characters of a paste + were left out, instead of silently stopping. +- The context ring's opened label has a Compact button that sends `/compact` in one tap, shown only + for agents that list the command. +- A **Quote in message** button appears after you select text in the terminal and adds the + selection to that pane's chat draft, headed by the pane name and line range. It only fills the + draft; nothing is sent. +- Skill names an agent writes in an answer (`/name`, or `$name` for Codex) show as chips, matched + against the pane's own skill list, and copy as the raw text. +- The diff viewer's header gains **Copy path** (the repository-relative path, with no patch + needed), and the viewer and the Changes list draw placeholder rows while they load so the layout + holds. +- The command palette finds Settings pages and actions by related words (try "theme" or "font"), + opens the page directly, and lists matches found only through such words after title matches. +- File rows in the Files dialog's Browse and Changes lists and in the folder browser show a glyph + for the file's kind (code, JSON, config, image, archive, lockfile, ...) and mark a `.git` folder, + so a list can be scanned at a glance. +- With Theme set to System, **Settings → Appearance** picks separate Light and Dark palettes and + switches between them with the OS. +- **Settings → Shortcuts** records a binding by pressing it (Escape cancels; reserved, unbindable + and already-assigned chords are refused on the row), and a search field filters the shortcut + list. +- Long native conversations mount a window of rows while keeping selected and focused + rows available. Reading position survives older messages and streaming; expanded work + and fetched full outputs survive scrolling away. Chat Find (Cmd/Ctrl+F) searches loaded + source, including folded work, and can search earlier pages with cancellation and retry. +- **Files → Changes** lists what changed in the repository a pane sits in: the working tree, the + index, or two commits named by hand, one row per file with git's status letter, the path (a + rename's old path too) and its added and removed lines. A row opens the file's diff from + `@pierre/diffs` as a bounded window of rows, unified or side by side (Settings → File viewer → + Diff layout; a phone shows one column and says so), with old and new line numbers, the context + around each change folded to a line or shown whole, the file itself one tap away on the PC that + holds it, and the patch copied as git printed it. Binary, too-large, conflicted and vanished + changes, a folder outside git and a commit that does not exist each say what they are. Back, + Escape and Close each close the diff alone and return the focus to its row; a remote PC's + repository reads through its bridge; the demo answers from its fixture. +- The file viewer opens a source file whole up to 8 MB and 200 000 lines (a Markdown file keeps + its 256 KB Preview), and draws a source of more than 2 000 lines as a window of rows from + `@pierre/diffs`, so a 120 000-line file scrolls with a few hundred rows in the page. Up to + 256 KB and 5 000 lines the rows are colored in the package's own worker; a longer source stays + plain and says so. A line reference reveals its line against the mounted row, on a phone with + wrapping too; a cut file says whether its bytes or its lines ended it; and Copy, without a + clipboard, shows the whole source as a selected text box. +- File links with `:line[:column]` or `#Lline[-Lline]` open the source at the first + requested line, including Markdown reader tabs. Lines outside the bounded preview + are explained rather than silently opening at the top. +- The command palette browses folders: type a path such as `~/` (or pick **Browse folders**) to walk + the PC's folders, jump to the folder of a pinned agent or a recent folder, and open the one you + reach in Files. The Files dialog has a button that opens the palette already browsing its current + folder. +- In Settings, the Chat font and Terminal font fields can be picked from a searchable, previewed list of + the fonts installed on this device in Chromium browsers, and the terminal refuses proportional + fonts. +- **Settings → Appearance → Typography** sets the interface font size on this device, apart from the + terminal's and the chat's own. +- Coming back to a pane's chat puts you on the same turn and offset you left, or at the end if you + were following it; the place is remembered per pane and PC for the life of the tab. +- A conversation of four or more turns shows a rail of short dashes left of the chat, one per + message you sent with the one in view marked; click a dash, or focus it and press Enter, to jump + to that turn. It is hidden on a phone and wherever the chat column leaves no margin. + +### Fixed +- The diagram viewer's toolbar keeps clear of a notch or rounded corner on a phone held sideways. +- A long Markdown file opens its Preview in parts, so the page no longer stalls for about a second on a slow machine while it draws. diff --git a/changelog.d/tab-switch-keeps-panes.md b/changelog.d/tab-switch-keeps-panes.md new file mode 100644 index 000000000..1167efbe8 --- /dev/null +++ b/changelog.d/tab-switch-keeps-panes.md @@ -0,0 +1,9 @@ +### Changed +- Switching back to one of the last few tabs shows its terminal and chat at once instead of + rebuilding them: their panes stay mounted while hidden, attached terminals let go of the pty, + and attach again on return. Mirrored panes and the embedded demo keep their subscriptions. +- A chat opened again draws the conversation it last read straight away and checks for news in + the background, instead of showing "Loading conversation…" first, and it reads once per + switch instead of twice. +- Hidden chats, including split siblings on a phone, pause conversation watches and optional + terminal-output reads until they are shown again. diff --git a/changelog.d/tailnet-exposure-notice.md b/changelog.d/tailnet-exposure-notice.md new file mode 100644 index 000000000..2ace3689b --- /dev/null +++ b/changelog.d/tailnet-exposure-notice.md @@ -0,0 +1,3 @@ +### Changed +- Hide the network exposure notice and startup warning when the server binds only to a + Tailscale address. LAN and wildcard binds still warn; access and authentication stay unchanged. diff --git a/changelog.d/tailscale-forwarded-login.md b/changelog.d/tailscale-forwarded-login.md new file mode 100644 index 000000000..fe7da03f7 --- /dev/null +++ b/changelog.d/tailscale-forwarded-login.md @@ -0,0 +1,3 @@ +### Fixed +- Tailscale login headers forwarded by another local proxy no longer grant owner access unless + the forwarded address is a single tailnet address. Header-free, serve-only owner access is preserved. diff --git a/changelog.d/telemetry-all-settings.md b/changelog.d/telemetry-all-settings.md new file mode 100644 index 000000000..42bea80b7 --- /dev/null +++ b/changelog.d/telemetry-all-settings.md @@ -0,0 +1,5 @@ +### Changed +- Anonymous usage counts now also report the light and dark palettes System mode switches between, + the interface font size, the diff layout and whether the usage meters' accounts were ever + reordered (a yes or no, never the accounts), and no longer report the settings removed in this + release. diff --git a/changelog.d/telemetry-notice-one-line.md b/changelog.d/telemetry-notice-one-line.md new file mode 100644 index 000000000..f7ca92be2 --- /dev/null +++ b/changelog.d/telemetry-notice-one-line.md @@ -0,0 +1,3 @@ +### Changed +- The line about anonymous install and update counts is one short sentence on one row. Its Turn off + button is gone: What is sent opens Settings → About, where the switch is. diff --git a/changelog.d/terminal-input-autocorrect-and-key-repeat.md b/changelog.d/terminal-input-autocorrect-and-key-repeat.md new file mode 100644 index 000000000..0c25dafc9 --- /dev/null +++ b/changelog.d/terminal-input-autocorrect-and-key-repeat.md @@ -0,0 +1,6 @@ +### Fixed +- The terminal input line turns off the phone keyboard's autocorrect and spell-check, as the + composer does, so a flag, path or branch name is no longer swapped for a dictionary word before + Send. +- Holding ⌫, Del, Home, End, PgUp or PgDn in the touch key bar repeats it until release, as a held + arrow already did, instead of sending one key. Esc, Tab, Enter and ^C still send once. diff --git a/changelog.d/terminal-input-send-during-composition.md b/changelog.d/terminal-input-send-during-composition.md new file mode 100644 index 000000000..cd136e0cb --- /dev/null +++ b/changelog.d/terminal-input-send-during-composition.md @@ -0,0 +1,4 @@ +### Fixed +- The terminal input line's Send no longer does nothing when tapped while the keyboard is still + composing the last word (Gboard, Korean, Japanese and Chinese keyboards): the tap finishes the word + and sends the line, and says "Finish the word, then send." when the keyboard still does not commit it. diff --git a/changelog.d/terminal-input-waits-for-the-snapshot.md b/changelog.d/terminal-input-waits-for-the-snapshot.md new file mode 100644 index 000000000..b318e3976 --- /dev/null +++ b/changelog.d/terminal-input-waits-for-the-snapshot.md @@ -0,0 +1,4 @@ +### Maintenance +- The terminal input check waits for the snapshot before it asks for a liveness probe: on CI the + snapshot can follow the pane's first frames by a few milliseconds, and a wake before it probes + nothing. diff --git a/changelog.d/three-palettes.md b/changelog.d/three-palettes.md new file mode 100644 index 000000000..8d4577412 --- /dev/null +++ b/changelog.d/three-palettes.md @@ -0,0 +1,3 @@ +### Removed +- The Dark report, Lilac and Forest color palettes. Settings that chose one of them now open in + Amber; Amber, Charcoal and Catppuccin remain. diff --git a/changelog.d/touch-scroll-by-finger-travel.md b/changelog.d/touch-scroll-by-finger-travel.md new file mode 100644 index 000000000..7dcd47cba --- /dev/null +++ b/changelog.d/touch-scroll-by-finger-travel.md @@ -0,0 +1,4 @@ +### Fixed +- Dragging the terminal on a phone now scrolls by how far the finger travels: a quick swipe goes as + far as a slow drag over the same distance, one scroll step per three rows of travel, and a short + flick of at least half a step still scrolls once when the finger lifts. diff --git a/changelog.d/trim-pane-and-header-menus.md b/changelog.d/trim-pane-and-header-menus.md new file mode 100644 index 000000000..bceccd54d --- /dev/null +++ b/changelog.d/trim-pane-and-header-menus.md @@ -0,0 +1,16 @@ +### Removed +- **Move pane to…** in the header's More menu, and `POST /api/pane/move` behind it. Move a pane + in herdr's TUI; the app follows the roster as before. +- **Send right-clicks to pane** in the pane menu: a right-click on a pane always opens its menu. +- **New tab** in the header's More menu: the tab strip's `+` and the command palette open one. +- **Command palette** in a phone's More menu and **Browse folders in the palette** in its Files + dialog. The command palette and its plugin actions are not available in the phone touch UI; + use a wider screen or an external keyboard's Mod+Shift+K shortcut. + +### Changed +- The pane header's `⋯` stays visible on touch screens and appears on keyboard focus on desktop; + tab to it and press Enter, or right-click the pane with a mouse. Shift+F10 in the terminal + stays with the terminal program. +- The tab's `⋯` on touch screens no longer lists New tab (the strip's `+` sits beside it). + At widths that show one pane at a time, it omits Split right, Split down and Zoom pane, but + keeps Unzoom pane so other panes remain reachable. Wider touch screens keep split and zoom. diff --git a/changelog.d/trim-settings-2.md b/changelog.d/trim-settings-2.md new file mode 100644 index 000000000..9973849ff --- /dev/null +++ b/changelog.d/trim-settings-2.md @@ -0,0 +1,5 @@ +### Removed +- Four more settings: Page overlap (one line stays between pages), Files at once (one paste, pick + or drop takes up to 100 files), Highlight code (always on) and PageUp / PageDown scroll chat + (plain page keys now always scroll the chat transcript, also while typing; in the terminal they + still go to the running program). diff --git a/changelog.d/trim-settings.md b/changelog.d/trim-settings.md new file mode 100644 index 000000000..f749472a9 --- /dev/null +++ b/changelog.d/trim-settings.md @@ -0,0 +1,5 @@ +### Removed +- Five settings, each now fixed at what most devices already used: Show pace for the plan meters + (off), the Suggestion chip switch (always shown), Fit 80 columns in a split pane (always on), and + the terminal's Cursor shape and Blinking cursor (a blinking block). A program in a pane can still + change its cursor. diff --git a/changelog.d/turn-time-midnight.md b/changelog.d/turn-time-midnight.md new file mode 100644 index 000000000..e6c3ad3a5 --- /dev/null +++ b/changelog.d/turn-time-midnight.md @@ -0,0 +1,3 @@ +### Fixed +- Chat turn times follow the day change: a turn shown as today's time reads "yesterday at …" after + local midnight without reopening the chat, from one shared timer that re-renders only the times. diff --git a/changelog.d/ui-regression-phone-plan-and-split-drag.md b/changelog.d/ui-regression-phone-plan-and-split-drag.md new file mode 100644 index 000000000..5e75ec75e --- /dev/null +++ b/changelog.d/ui-regression-phone-plan-and-split-drag.md @@ -0,0 +1,4 @@ +### Maintenance +- The browser run's phone and desktop plan checks no longer lose the plan chip to the roster the + server pushes over its event stream, and its split-view drag waits for each step the page shows + before the drop. diff --git a/changelog.d/viewport-tools-idle-backoff.md b/changelog.d/viewport-tools-idle-backoff.md new file mode 100644 index 000000000..1ecd9d633 --- /dev/null +++ b/changelog.d/viewport-tools-idle-backoff.md @@ -0,0 +1,6 @@ +### Fixed +- A terminal pane left open and quiet no longer asks herdr for its scroll position every second: + the scrollbar's poll backs off to every 15 seconds and speeds up again on output, scrolling, + resizing or returning to the tab. A view scrolled up into history keeps its 1 second read, since + herdr holds that view still while new output grows the history below it, and a failed read + still retries after 1 second. Each drawn frame also no longer measures the page layout unless a search has highlights to place, which saves battery on phones. diff --git a/changelog.d/windows-closed-pipe-write.md b/changelog.d/windows-closed-pipe-write.md new file mode 100644 index 000000000..432c172ea --- /dev/null +++ b/changelog.d/windows-closed-pipe-write.md @@ -0,0 +1,3 @@ +### Fixed +- On Windows, a failed write to a helper process that already closed its input no longer ends the + server. diff --git a/changelog.d/windows-omo-catalog.md b/changelog.d/windows-omo-catalog.md new file mode 100644 index 000000000..e1a9bdd1f --- /dev/null +++ b/changelog.d/windows-omo-catalog.md @@ -0,0 +1,2 @@ +### Fixed +- On Windows, OmO is offered when creating a workspace or a tab, as gjc already was. diff --git a/changelog.d/windows-plugin-stop-waits-tree.md b/changelog.d/windows-plugin-stop-waits-tree.md new file mode 100644 index 000000000..2917abd84 --- /dev/null +++ b/changelog.d/windows-plugin-stop-waits-tree.md @@ -0,0 +1,2 @@ +### Fixed +- Stopping the Windows plugin waits until its original process tree has exited. diff --git a/changelog.d/windows-remote-xdg-config-home.md b/changelog.d/windows-remote-xdg-config-home.md new file mode 100644 index 000000000..6662dbf07 --- /dev/null +++ b/changelog.d/windows-remote-xdg-config-home.md @@ -0,0 +1,2 @@ +### Fixed +- Windows remote PCs now connect to the herdr socket under `XDG_CONFIG_HOME` when set. diff --git a/changelog.d/windows-test-session-socket.md b/changelog.d/windows-test-session-socket.md new file mode 100644 index 000000000..7b60db237 --- /dev/null +++ b/changelog.d/windows-test-session-socket.md @@ -0,0 +1,5 @@ +### Maintenance +- On Windows, the browser and SSH scripts find the test herdr session: they look under + `XDG_CONFIG_HOME` first, then `%APPDATA%`, as herdr does. + ([#884](https://github.com/devswha/herdr-web-ui/pull/884) by @willowite, + [#1032](https://github.com/devswha/herdr-web-ui/pull/1032)) diff --git a/changelog.d/workspace-tab-name-limits.md b/changelog.d/workspace-tab-name-limits.md new file mode 100644 index 000000000..c05c2d93a --- /dev/null +++ b/changelog.d/workspace-tab-name-limits.md @@ -0,0 +1,4 @@ +### Fixed +- A workspace or tab name can no longer be saved blank or longer than 80 characters: the server + refuses it, the name fields stop at 80, and clearing a name in the sidebar or the tab strip says + a name is needed instead of saving nothing or ignoring the Enter. diff --git a/changelog.d/worktree-menu-on-repository-workspace.md b/changelog.d/worktree-menu-on-repository-workspace.md new file mode 100644 index 000000000..39077fd12 --- /dev/null +++ b/changelog.d/worktree-menu-on-repository-workspace.md @@ -0,0 +1,3 @@ +### Fixed +- A workspace made on a repository's folder offers New worktree and Open worktree… in its row menu + again. Since 0.4.6 the two showed only once the repository already had a worktree open. diff --git a/changelog.d/worktree-without-commits.md b/changelog.d/worktree-without-commits.md new file mode 100644 index 000000000..5920f94e4 --- /dev/null +++ b/changelog.d/worktree-without-commits.md @@ -0,0 +1,3 @@ +### Fixed +- New worktree in a repository that has no commit yet says so, instead of showing git's + `fatal: invalid reference: HEAD`. diff --git a/changelog.d/ws-connect-deadline.md b/changelog.d/ws-connect-deadline.md new file mode 100644 index 000000000..0ba2195a1 --- /dev/null +++ b/changelog.d/ws-connect-deadline.md @@ -0,0 +1,7 @@ +### Fixed +- A connection attempt that hangs (a phone waking on a dead network) is given up after 8 seconds + and tried again with a longer wait each time (up to 30 seconds), so a slow but working link + such as a remote PC still connects, instead of leaving the app disconnected until reload. +- A page coming back to the screen, or a network coming back, with a dead connection reconnects + at once, even when the browser had not yet reported the connection closed or a retry was + still waiting out a long deadline. diff --git a/changelog.d/ws-liveness-probe.md b/changelog.d/ws-liveness-probe.md new file mode 100644 index 000000000..c9de02164 --- /dev/null +++ b/changelog.d/ws-liveness-probe.md @@ -0,0 +1,14 @@ +### Fixed +- A connection that went dead without closing (a phone moving from Wi-Fi to cellular, or waking + after its network changed) no longer leaves the terminal frozen while it still shows connected. + The app checks the connection when the page comes back, the network changes or nothing arrived + for 20 seconds, and reconnects when two checks a few seconds apart get no answer; a slow link + gets longer each time it is dropped. Keys typed during the check are kept as held input instead + of being lost, and a message sent during the check waits for the answer instead of failing. The + key bar comes back as soon as the answer does, and a PC on an older version is no longer dropped + after a reconnect. +- Once terminal input is held, later typing joins the held input until you send or discard it, + also after the terminal is ready again: a command is never split between the held text and the + terminal, and Enter never runs the part that already reached it. Ctrl+Enter, a paste the held + input cannot take (a tab, for one), the input line's Send and the message box's Send (a queued + "Send now" too) are refused with a note saying to send or discard the held input first. diff --git a/docs/chat-mode-audit.md b/docs/chat-mode-audit.md index 876b22b84..356e4b13c 100644 --- a/docs/chat-mode-audit.md +++ b/docs/chat-mode-audit.md @@ -172,31 +172,53 @@ Large-output chunk APIs and a broader provider abstraction remain deferred: whole-output reads retain their existing 2,000,000-character response cap and still read the native file on demand. This change does not alter the input path. -### GJC pane binding (2026-09-28) - -GJC resolution prefers a unique open JSONL file under the canonical -`~/.gjc/agent/sessions/` store, belonging to the requested pane's GJC process and -matching the pane cwd in its session header. When the writer does not keep that file -open, the bridge reads GJC's native `~/.gjc/agent/terminal-sessions/` -breadcrumb (cwd and exact session path on separate lines). The key comes from the -running process's terminal; the breadcrumb must have been written during that process's -lifetime. Its cwd, canonical store containment, and transcript header are checked. -Linux uses `/proc`; macOS uses `ps` for the tty and process start time. macOS metadata -parsing is fixture-tested; an end-to-end macOS run is still required. - -For GJC builds that publish neither signal, a bounded scan of the native store matches -substantial assistant text against the requested pane's visible output. Exactly one -same-cwd transcript must match; short/generic text and duplicate matches are rejected. -The local GJC 0.17.2 binary was verified with an isolated resumed session, no model -request, and no open transcript or breadcrumb: its native answer resolved correctly. - -Directory descriptors, cwd matches alone, and transcript modification times do not establish -ownership. Missing, unreadable, stale or ambiguous evidence still returns -`no_session_path`; visible-text recovery also requires the identifying answer to remain -on screen. Custom GJC stores and terminal IDs that cannot be resolved remain -unsupported. No newest-file fallback is restored. - -`server/gjc.contract.test.ts` covers distinct open files and fresh per-terminal -breadcrumbs in same-cwd panes, session switching, stale breadcrumbs and ambiguous -files against an isolated herdr session. `server/gjc-runtime.test.ts` checks macOS -metadata parsing and rejects mismatched cwd, path traversal and symlink escapes. +### GJC pane binding + +GJC identity comes from its public broker-bound SDK CLI, not terminal content. +`server/gjc-sdk.ts` asks `gjc sdk session raw global --op session.list --page` +with attach-only mode and the SDK's workspace scope. A bounded Git root probe +supplies the scope's worktree identity; the broker validates it. Discovery matches +the requested pane's GJC process to a unique live SDK session, then resolves that exact session ID to the broker's +`savedSession` path. The two responses must agree on process and session identity. +Discovery is limited to eight pages of 100 rows in that workspace, an eight-second +total deadline and two seconds per command, with at most 1 MiB of command output. +An incomplete or ambiguous answer is not used; unrelated workspaces' retired +sessions do not consume the page budget. + +The native transcript must remain inside the canonical `~/.gjc/agent/sessions/` +store and its header must identify the selected session and workspace. The pane's +process is checked again after discovery. Session titles, visible answers, file +recency, open descriptors and terminal breadcrumbs no longer select a GJC chat. +The descriptor and breadcrumb helpers remain for omp, which still uses them. +GJC can retain a generation-zero direct-session GC fence after `/new`. Only the +known non-endpoint shape at the canonical agent root is excluded from candidate +selection; foreign-root or uncertain zero-generation rows still refuse a binding. + +This requires GJC 0.18.9 or later, for its public SDK session CLI, +on the server PC's PATH and an already running broker. The bridge does not start +or restart that broker, attach to private SDK endpoints, or register a live +session observer. Missing, stale or unavailable identity returns `no_session_path` +and leaves Terminal available. A new session without a saved transcript has no +native chat yet. Custom transcript stores remain unsupported. + +The first migration changes session identity only: herdr still owns terminals +and agent status; chat rendering, message submission and prompt answers retain +their existing contracts. Real Windows and macOS SDK integration require separate +validation. + +`server/gjc.contract.test.ts` reproduces the copied-neighbor-answer failure and +checks exact-PID binding, same-process session switches and fail-closed SDK errors +on isolated herdr panes. `server/gjc-sdk.test.ts` covers the broker contract and +bounded pagination; `server/gjc-runtime.test.ts` covers path/header and process +race checks, including Windows process-table fixtures. + +The opt-in `server/gjc-sdk-live.contract.test.ts` was run on Linux with real GJC +0.18.9, disposable HOME and herdr state, and no model requests. It resumes seeded +history, resolves the exact SDK-selected file despite an identical neighboring +answer, reads its chat, and refuses to fall back when the SDK CLI becomes +unavailable. It also runs `/new` in the real TUI and checks the new session's +SDK-selected history without sending a model request. Run it with: + +```sh +HERDR_GJC_SDK_TEST_BIN=/absolute/path/to/gjc bun run check run bun test --timeout 90000 ./server/gjc-sdk-live.contract.test.ts +``` diff --git a/docs/development.md b/docs/development.md index d89eae6a0..5e1a71853 100644 --- a/docs/development.md +++ b/docs/development.md @@ -42,8 +42,11 @@ listens on while it runs, so a run that died holds none. `CHECK_MAX_RUNS` sets h the default is one per eight cores, from 1 to 2. The contract and browser tests are bound by timing, so more runs side by side make them fail each other: on a 32-core PC three at once already lost Chromium screenshots in the browser lane, so raise it only to look for such failures. A run that finds every slot taken -names the holders and exits. So does a second run in the same checkout, even with slots free: -runs in one checkout share `dist/` and the generated types. The +names the holders and exits, or, with `CHECK_WAIT_SECONDS` (0 to 7200), waits that long for one +first, as CI's herdr-backed lanes do. A second run in the same checkout exits at once, even with +slots free: runs in one checkout share `dist/` and the generated types. A checkout is its real +path, plus `RUNNER_NAME` when that is set: the self-hosted runners of one PC share a loopback and +all check out to the same path. The generated-types check (`bun run generate:types --check`) is read-only, but `test:unit` includes `scripts/generate-protocol-types.test.ts`, which rewrites the generated file while it runs. Fast checks and the browser lane also build into `dist/`; avoid overlapping runs that write to the same @@ -84,8 +87,8 @@ the test runner. A report is evidence, not an authorization to merge or publish. A local pass is not CI's: the PC has its own Node (CI pins 22), its own cores and its own system libraries. The browser lane uses the lockfile's Chromium, which it downloads into Playwright's -cache on first use. `HERDR_TEST_SHARDS=4` runs four integration files at a time, as a way to -look for timing failures; the default is one, as in CI. +cache on first use. `HERDR_TEST_SHARDS=4` runs four integration files at a time, each with an +isolated herdr session. Local checks and hosted CI default to one; self-hosted CI uses four. The single commands still work on their own: @@ -97,7 +100,7 @@ bun test # needs herdr installed; creates and removes its bun run test:ui # browser regression against isolated test servers bun scripts/sticky-modifiers-regression.ts # mobile held keys through real legacy/Kitty PTYs bun scripts/key-bar-customization-demo-regression.ts # mobile key layout, saved combinations and migration on demo fixtures -bun scripts/key-bar-hold-repeat-regression.ts # phone touch-arrow taps, holds, swipes and sticky Ctrl on demo fixtures +bun scripts/key-bar-hold-repeat-regression.ts # phone touch key-bar taps, holds (arrows and editing keys), swipes and sticky Ctrl on demo fixtures bun scripts/settings-pages-demo-regression.ts # every Settings page on a 390px and a 320px phone, and Back stepping out of the dialog bun scripts/chat-browser-qa.ts # chat lens end to end bun scripts/output-browser-qa.ts # terminal output flow control end to end @@ -115,7 +118,7 @@ after a herdr restart. It prints the time each step took. `KEEP=1` leaves the co Tests run against a herdr session of their own, `herdr-web-ui-test`. The first run starts a headless `herdr --session herdr-web-ui-test server` and later runs reuse it, so test workspaces never show in the herdr you work in (`scripts/test-herdr.ts`). Stop it with `herdr --session herdr-web-ui-test server stop`. `HERDR_TEST_SESSION` picks another name, and `HERDR_TEST_LIVE=1` runs against `HERDR_SOCKET` or your default session instead. -Browser checks look for Chrome at `/opt/google/chrome/chrome`; set `CHROME_PATH` otherwise. After a herdr upgrade, refresh the generated wire types with `bun run generate:types --refresh` (and `--check` to verify). +Browser checks launch `CHROME_PATH` when it is set, else Google Chrome at `/opt/google/chrome/chrome` where that exists, else the lockfile's Playwright Chromium (`scripts/chrome-path.ts`; install it with `bun node_modules/playwright-core/cli.js install chromium`). After a herdr upgrade, refresh the generated wire types with `bun run generate:types --refresh` (and `--check` to verify). `HEADED=1 bun scripts/chat-browser-qa.ts` opens its owned native-transcript fixture in a visible Chrome window. Its detected Codex fixture process keeps the rollout open; no @@ -131,11 +134,11 @@ bun scripts/droplet-demo-regression.ts # real-app alerts below the header bun scripts/chat-greeting-demo-regression.ts # an empty chat's greeting: centred on a desktop, docked on a phone bun scripts/composer-fit-demo-regression.ts # one Send/Stop control, pending Send now actions, context/label fit and Chat font sizing bun scripts/held-rows-demo-regression.ts # held messages: the fold under an approval card, its button, a row's error -bun scripts/sidebar-activity-demo-regression.ts # Agents order Activity and Quiet opened finishes: blocked pinned, recency, an opened DONE drawn as ready +bun scripts/sidebar-activity-demo-regression.ts # Agents order Activity and pins: blocked pinned, recency, Pin agent / Unpin agent from the ⋯ and a right-click bun scripts/prompt-dock-demo-regression.ts # the prompt card docked over the input card: its place, its height on a short phone, the grip, a typed pick bun scripts/plugin-actions-demo-regression.ts # a plugin action that ends after the server stopped waiting: the late failure said, a traceback's height on a phone with its keyboard up bun scripts/font-swap-demo-regression.ts # the app's faces arriving late on a slow link: a reader at the end of a chat stays there, a tab strip the user scrolled stays put -bun scripts/narrow-pane-demo-regression.ts # a split pane under 80 columns: the smaller font, its setting, a zoom, and a pane no font can bring to 80 +bun scripts/narrow-pane-demo-regression.ts # a split pane under 80 columns: the smaller font, a zoom, and a pane no font can bring to 80 ``` `FILE_VIEWER_CASE=landscape-notch` selects a viewer case; `FILE_VIEWER_CSS=/path/to/before.css` compares another stylesheet. These checks use Chromium mobile emulation and synthetic safe-area/keyboard geometry; they cannot verify actual iOS Safari keyboard dismissal or notch insets. The existing `bun scripts/file-viewer-regression.ts` separately checks history with an owned herdr pane. The original `scripts/mobile-viewport-regression.ts` exports `checkMobileViewport` for the real-app `bun run test:ui` suite; it also checks the command palette and xterm focus transitions. The demo runners build the real client into a temporary directory, inject the committed fictional-session transport and serve it only on loopback; they do not use a live herdr session or download website media. Run on its own, each builds the client itself (`scripts/demo-build.ts`); the browser lane builds it once and names the directory in `HERDR_DEMO_BUILD`, and each runner copies that instead. They exercise real-app viewport and alert geometry, but not live herdr connectivity. @@ -154,6 +157,55 @@ retried automatically. The restart gate waits for the interrupted turn's readine by the new turn's working/ready cycle. If Codex never exposes that cycle, remaining messages are held for explicit review. +## GJC parent-turn status + +GJC status follows the native transcript bound to that pane, rather than a provider child's stale status or session ID. A user message, tool result or tool call keeps the parent turn working; an explicit final answer ends it. Native background-result, monitor and scheduled-task notifications start another turn, and an open `ask` waits for input. + +This is parent-turn state, not a count of live background jobs. GJC's job manager keeps that live state in memory, so historical job starts are not treated as proof that a job still runs. Missing or unreadable native evidence leaves herdr's status in charge; interrupted old turns are not successful completions. The isolated `scripts/gjc-status-browser-regression.ts` fixture exercises the real server and browser without invoking a provider or touching existing sessions. + +## OmO runtime recognition + +A pane runs OmO when its foreground processes prove it. The first proof is the program +itself (`isOmoProcess`): omo's own binary, the omo-ai script a JS runtime runs, the +claude-sdk child, or a hoisted senpi entry loading omo-ai's plugin. + +A launcher whose own argv says nothing — a renamed entrypoint, a profile wrapper — is +recognized only by native session ownership, verified read-only on every lookup +(`omoProcessIdentity`): + +- its environment as it was at `exec` resolves to an OmO store: an explicit + `OMO_CODING_AGENT_DIR` entry (an empty value still means the default), or no entry at + all so the store stays the default `~/.omo/agent`. A store moved only by + `SENPI_CODING_AGENT_DIR` or `PI_CODING_AGENT_DIR` is not an OmO identity; +- the OS reports a live start time for the exact pid, and the store's + `sessions//session-holders/` keeps a holder record naming that pid, a + matching `processStartedAtMs` and the process's own cwd. + +An environment marker alone, an argv mention, a stale or reused pid, a foreign cwd or +an unreadable start never identify a custom OmO, and a custom runtime never falls back +to cwd/time inference, file mtimes or launch hints — only its current holders bind it, +so `/new` reports the pending held id and releasing a holder never resurrects an older +transcript. Binding is per owning store: a held or reported session id, a pending check +and an open descriptor match only inside the canonical stores that pane's own processes +resolved, so the same id in two stores stays two independent sessions and two panes +claiming one file fail closed. A held claim survives a missing or unreadable session +file with its store scope intact — a broken header never resolves to another store's +same-id file, and a holder whose own store cannot be resolved claims nothing. + +The folder a pane binds is the one its OmO processes actually run in: a process-reported +cwd wins over the pane's `foreground_cwd`/`cwd`, which count only when no process reports +(`omoTranscriptsOfCwd`, `omoSessionForPane` and `omoPanes` resolve it the same way, and +same-cwd peers are the panes resolving to that folder, not the ones sharing a stale pane +cwd). A pane whose holders run in two different cwds is ambiguous and selects nothing. + +The process evidence lives in `/proc`: a launcher argv does not identify is verifiable +only where `/proc//environ` and `/proc//cwd` (or a reported process cwd) are +readable — Linux today; on macOS or Windows such a launcher stays unrecognized until +its argv is added to `isOmoProcess`. A custom launcher must therefore set the store in +the child's environment before `exec` (`OMO_CODING_AGENT_DIR=… my-launcher`): a +variable the process sets only in-process later may never appear in +`/proc//environ`, which reports the environment the kernel recorded at `exec`. + ## Conversation refresh The chat reads `/api/pane/conversation` after a `conversation-changed` WebSocket frame. @@ -331,14 +383,16 @@ the page and the data follows. The SoftwareApplication data is written in the pa The build also copies the retained `site/assets/` and `site/media/` files, including the film linked from the README and its chat loop. These remain available at their existing URLs even though the homepage uses the desktop and phone demos. -`.github/workflows/pages.yml` installs ffmpeg and runs on pushes to `main` and manual dispatches from -`main`. Reusable CI validation and the site build run in parallel against the same triggering SHA, -and deployment requires both to succeed. Deployments are serialized. Immediately before deploying, +`.github/workflows/pages.yml` installs ffmpeg when the runner lacks it and runs after CI completes +on `main`, or on manual dispatches from `main`. A completed CI run only builds and deploys when it +succeeded for a push from this repository; PR runs and forks cannot publish. It builds that CI run's +head SHA without running validation again. Manual dispatches run reusable CI validation and the site +build in parallel against the dispatch SHA, and deployment requires both to succeed. +Deployments are serialized. Immediately before deploying, the workflow checks that the validated SHA is still the latest `main` commit; it fails closed if it is stale or that check cannot be completed. This does not change the separate Release workflow. -The standalone `main` CI also remains enabled: a main push deliberately runs validation twice -on separate runners, preserving its independent CI history while keeping the deployment gate local -to the Website workflow. +The standalone `main` CI supplies the validation for each main push, preserving its independent CI +history while the Website workflow waits for its successful completion. ### The browser demo @@ -356,19 +410,30 @@ after changing the staged session. Files, images, push and remote PCs are not pa ## Releasing 1. Open a release PR that bumps `version` in `package.json` and `herdr-plugin.toml`, - moves the `Unreleased` notes in [CHANGELOG.md](../CHANGELOG.md) under the new version, - and tells the release in [release-summaries.json](../release-summaries.json) the way a - game's patch notes do: under the version, for each of `en`, `ko`, `ja` and `zh`, the lists - `new`, `improved` and `fixed` (a list with nothing to say is left out), each a few lines of + runs `bun scripts/changelog-fold.ts X.Y.Z [YYYY-MM-DD]` (it moves the `Unreleased` notes in + [CHANGELOG.md](../CHANGELOG.md) and the [changelog.d/](../changelog.d/README.md) fragments under + the new version, dated today in local time by default, links each fragment's PR and deletes the + fragments; then add the `by @login` credits; a fragment merged later must be moved into the + version by hand, since the release workflow refuses while one is left), and tells the release + in [release-summaries.json](../release-summaries.json) the way a game's patch notes do: under + the version, for each language in `SUMMARY_LANGUAGES` (`shared/update.ts`: `en`, `ko`, `ja` and `zh`), the lists `new`, `improved` and `fixed` (a list with nothing to say is left out), each a few lines of plain text, 90 characters at most, the same number of lines in every language. It is what an install shows before and after the update, with the changelog section folded under it, so a line names what changes for the people who use the app and leaves out PR numbers and - internals. The unit suite and the release workflow fail without all four languages. + internals. The unit suite and the release workflow fail without every language. The GitHub release is written from the same files by `scripts/release-notes.ts`: the English lists under New features, Improvements and Bug fixes, then the version's whole changelog section folded under **Full changelog**. + When the release guards (below) say so, the same PR raises `REMOTE_BUNDLE_VERSION` in + `shared/machines.ts`, and after deploying the telemetry Worker + ([telemetry/README.md](../telemetry/README.md#deploy)) commits `telemetry/deployed.json` written by + `bun scripts/release-guards.ts --worker-deployed`. 2. Merge it after CI passes. -3. Run **Actions → Release → Run workflow**, select `main`, and enter `X.Y.Z` without `v`. +3. If it raised `REMOTE_BUNDLE_VERSION` to N, push the tag `remote-vN` on the merged commit and wait + for the **Remote PC bundles** workflow to publish it ([remote PCs](remote-pcs.md)). Merge nothing + that touches `server/`, `shared/`, `bun.lock` or `patches/` until the Release run is done: the + Release run would then ask for a follow-up PR that raises the number again and a new tag. +4. Run **Actions → Release → Run workflow**, select `main`, and enter `X.Y.Z` without `v`. The CLI equivalent is `gh workflow run release.yml --ref main -f version=X.Y.Z`. The workflow validates metadata, then runs the same unit, integration and browser checks @@ -379,7 +444,15 @@ visible to them even without a GitHub release. Existing tags cannot be reused; f published version with a new patch release. If publishing fails after a tag was created, verify that tag's commit and repair its GitHub release rather than moving the tag. -Remote-PC runtime bundles are released separately: raise `REMOTE_BUNDLE_VERSION` in `shared/machines.ts` and push a `remote-vN` tag. See [remote PCs](remote-pcs.md). +Remote-PC runtime bundles are published by a `remote-vN` tag, and an app release requires the bundle +that matches its server (step 3). See [remote PCs](remote-pcs.md). + +`scripts/release-guards.ts` fails a release PR (CI's Fast checks) and the Release run when the bundle's +inputs changed since the published `remote-vN` without a raised `REMOTE_BUNDLE_VERSION`, when the raised +one is not published yet (Release only), when `telemetry/worker.ts` or `shared/telemetry.ts` changed +since the deploy recorded in `telemetry/deployed.json` ([telemetry/README.md](../telemetry/README.md#deploy)), +or when the step 1 metadata is incomplete. In CI only the change that bumps the version is checked; +PRs merged before the Release run pass, and the Release run checks the whole tree again. ## Pull requests and CI @@ -434,13 +507,19 @@ The [CI workflow](../.github/workflows/ci.yml) runs on every PR and `main` push: - **Integration tests** runs `bun run check integration`: a build and `bun run test:integration` on a herdr of the run's own ([Checks](#checks)). The integration files can run a few at a time, each worker on a herdr session of its own - (`scripts/ci-tests.ts`); they run one by one (`HERDR_TEST_SHARDS`, default 1 in - `scripts/check.ts`) until the timing-bound contract tests hold under load. + (`scripts/ci-tests.ts`); hosted CI runs one by one, while self-hosted CI sets + `HERDR_TEST_SHARDS=4`. `scripts/check.ts` still defaults to one for local checks. Missing herdr fails the run. Its herdr servers are stopped even on failure. Integration tests have a 15-second default timeout so their bounded process-startup probes can finish; individual tests can still specify a longer timeout. - - **Browser regression (ui-regression)** runs `scripts/ui-regression.ts` alone. - - **Browser regression (other scripts)** runs every other browser script, four at a time. + - **Browser regression (ui-regression)** runs `scripts/ui-regression.ts` and + `scripts/ui-contexts-regression.ts` concurrently. The core's stateful flow stays serial; + the extracted feature checks own a separate temporary herdr config/session, Chromium + process, server and fixture panes. Those helpers stay sequential within their own fixture. + The context suite prints each helper's elapsed time and stops its owned daemon on exit. + `bun run test:ui` also runs both suites. + - **Browser regression (other scripts)** runs every other browser script, four at a time + on hosted CI and six at a time on self-hosted CI. Both browser jobs run `scripts/ci-browser-parallel.ts`, which takes its script list from `scripts/ci-browser.sh` (`BROWSER_SCRIPTS` picks from it, `BROWSER_EXCLUDE` leaves scripts out, @@ -457,6 +536,22 @@ every minute a script is still running), and keeps running the other scripts aft Each script's full output goes to `${CHECK_DIR:-.ci}/browser-logs/', { headers: { "Content-Type": "text/html" } }) : new Response(Bun.file(join(root, path.slice(1)))); } }); - browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); + browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); // The fixture follows the browser's language; its text assertions below use English. const page = await browser.newPage({ locale: "en-US" }); page.setDefaultTimeout(10_000); const errors: string[] = []; page.on("pageerror", (error) => { errors.push(error.message); console.error(error.stack); }); let epoch = "one", newest = "old newest", before: string | null = "one:100"; - let holdOlder = false, olderRequested = false; + let holdOlder = false; let product = false; let unavailable = false, terminalFails = false; - let holdTerminal = false, terminalReads = 0; + let holdTerminal = false; let terminalText = "Fictional terminal fallback"; /** product panes ("machine/pane") whose conversation requests wait for the script, and those that fail */ const held = new Set(), failing = new Set(); const pending: Array<{ key: string; answer: (status: number) => Promise }> = []; const user = (text: string) => ({ role: "user", ts: text, parts: [{ kind: "text", text }] }); await page.route("**/api/**/read?*", async (route) => { - terminalReads += 1; + routeEvents.dispatchEvent(new Event("terminal")); if (holdTerminal) await new Promise((resolve) => releases.push(resolve)); await (terminalFails ? route.fulfill({ status: 503, json: { error: { code: "timeout", message: "fictional terminal read failed" } } }) @@ -68,7 +80,10 @@ try { : route.fulfill({ status, json: { error: { code: "unavailable", message: "conversation unavailable" } } }); // answered as it arrives unless its pane is held: a poll an abandoned mount left // behind can then never stand in for the request an assertion waits on - if (held.has(key)) await new Promise((resolve) => pending.push({ key, answer: (status) => answer(status).finally(resolve) })); + if (held.has(key)) await new Promise((resolve) => { + pending.push({ key, answer: (status) => answer(status).finally(resolve) }); + routeEvents.dispatchEvent(new Event(key)); + }); else await answer(failing.has(key) ? 503 : 200); return; } @@ -83,7 +98,7 @@ try { return; } if (url.searchParams.has("before")) { - olderRequested = true; + routeEvents.dispatchEvent(new Event("older")); if (holdOlder) await new Promise((resolve) => releases.push(resolve)); await route.fulfill({ json: { source: "omp-transcript", history_id: captured, cursor: null, turns: [user("old earlier")] } }); } else { @@ -115,13 +130,16 @@ try { // Start an older-page request, then observe clear before it completes. holdOlder = true; + const olderStarted = nextRoute("older"); await page.locator(".chat-older").click(); - while (!olderRequested) await Bun.sleep(20); + await olderStarted; epoch = "two"; newest = ""; before = null; await refresh(); await page.getByText("No conversation yet — say something below", { exact: true }).waitFor(); + const olderAnswered = page.waitForResponse((response) => new URL(response.url()).searchParams.has("before")); releases.splice(0).forEach((release) => release()); - await page.waitForTimeout(100); + await (await olderAnswered).finished(); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); assert.equal(await page.locator(".chat-turn").count(), 0, "late older page cannot revive cleared history"); assert.equal(await page.locator(".chat-older").count(), 0); console.log("PASS clear while earlier page is in flight and empty history before a new prompt"); @@ -189,13 +207,10 @@ try { await page.locator(".work-row-head").click(); await page.locator(".chat-tool-more").click(); const lateOutput = await page.evaluate(() => window.qa.requests.length - 1); - const readsBefore = terminalReads; terminalFails = terminalFailure; unavailable = true; holdTerminal = true; + const terminalStarted = nextRoute("terminal"); await refresh(); - for (const deadline = Date.now() + 10_000; terminalReads === readsBefore;) { - assert.ok(Date.now() < deadline, "missing terminal fallback request"); - await Bun.sleep(20); - } + await terminalStarted; await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); assert.equal(await page.getByText(newest, { exact: true }).count(), 0, "refused history must disappear while terminal fallback is still pending"); holdTerminal = false; @@ -254,12 +269,9 @@ try { for (const status of [200, 503]) { held.add("remote-pc/b"); const ended = await page.evaluate(() => window.qa.answered.filter((key) => key === "remote-pc/b").length); + const remoteStarted = nextRoute("remote-pc/b"); await select("b", "remote-pc"); - const deadline = Date.now() + 10_000; - while (!pending.some((request) => request.key === "remote-pc/b")) { - assert.ok(Date.now() < deadline, "missing delayed B request"); - await Bun.sleep(20); - } + await remoteStarted; await select("a", "remote-pc"); await page.getByText("history remote-pc/a", { exact: true }).waitFor(); held.delete("remote-pc/b"); diff --git a/scripts/chat-history-fixture.tsx b/scripts/chat-history-fixture.tsx index 6c9b41dcd..92d2614b0 100644 --- a/scripts/chat-history-fixture.tsx +++ b/scripts/chat-history-fixture.tsx @@ -6,6 +6,8 @@ import { PaneTerminal } from "../src/components/PaneTerminal.tsx"; import { ChatView } from "../src/components/ChatView.tsx"; import { useWholeOutput } from "../src/lib/useWholeOutput.ts"; import { MachineContext } from "../src/lib/machineContext.tsx"; +import { useUpdates } from "../src/lib/updates.ts"; +import { UpdateNotice } from "../src/components/UpdateControls.tsx"; import "../src/styles.css"; declare global { interface Window { qa: { requests: { url: string; signal: AbortSignal; resolve: (text: string) => void; reject: () => void }[]; commits: string[][]; answered: string[]; target: (url: string, scope: string) => void; chat: (pane: string, machine?: string) => void; select: (pane: string, machine: string, agent?: string) => void; refresh: () => void } } } @@ -29,6 +31,10 @@ window.fetch = (input, init) => { return new Promise((resolve, reject) => window.qa.requests.push({ url, signal: init!.signal as AbortSignal, resolve: (text) => resolve(new Response(text)), reject: () => reject(new Error("late failure")) })); }; +/** The app's update line as App mounts it, without a server to poll: math-browser-qa.ts reads its reload line. */ +function UpdatesLine() { + return {}} />; +} function Fixture() { const [target, setTarget] = useState({ url: "/qa-output?pane=a&ref=x", scope: "one" }); const [pane, setPane] = useState("a"); @@ -38,7 +44,7 @@ function Fixture() { window.qa.target = (url, scope) => setTarget({ url, scope }); window.qa.chat = (pane, machine = "local") => { setPane(pane); setMachine(machine); }; window.qa.refresh = () => setRefresh((value) => value + 1); - return <>{output.state}:{output.text} + return <>{output.state}:{output.text}
; } function Product({ pane, machine, agent }: { pane: string; machine: string; agent: string }) { diff --git a/scripts/chat-scroll-padding-fixture.tsx b/scripts/chat-scroll-padding-fixture.tsx new file mode 100644 index 000000000..bb767fb14 --- /dev/null +++ b/scripts/chat-scroll-padding-fixture.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import { createRoot } from "react-dom/client"; +import { LegendList } from "@legendapp/list/react"; + +declare global { + interface Window { + paddingQa: { header: (height: number) => void; add: (height: number) => void; resize: (by: number) => void }; + } +} + +/** The chat's list settings, with a header and rows whose heights the script sets. */ +function Fixture() { + const [heights, setHeights] = useState(() => Array.from({ length: 40 }, (_, index) => 60.37 + (index % 7) * 13.13)); + const [header, setHeader] = useState(20); + window.paddingQa = { + header: setHeader, + add: (height) => setHeights((list) => [...list, height]), + resize: (by) => setHeights((list) => list.map((value, index) => index === list.length - 1 ? Math.max(30, value + by) : value)), + }; + return ({ height, key: `row-${index}` }))} + keyExtractor={(row) => row.key} + renderItem={({ item }) =>
{item.key}
} + estimatedItemSize={80} + recycleItems={false} + initialScrollAtEnd + maintainVisibleContentPosition + ListHeaderComponent={
header
} + ListFooterComponent={
footer
} + />; +} + +const root = document.getElementById("root"); +if (!root) throw new Error("Padding fixture root missing"); +createRoot(root).render(); diff --git a/scripts/chat-scroll-padding-regression.ts b/scripts/chat-scroll-padding-regression.ts new file mode 100644 index 000000000..1c2293cd9 --- /dev/null +++ b/scripts/chat-scroll-padding-regression.ts @@ -0,0 +1,84 @@ +/** + * The chat's list (@legendapp/list) pads its content for a frame when it moves the scroll past the + * content's end, then removes the padding if it still reads back as set. Chromium rounds a long + * fraction ("214.1875px" reads "214.188px"), so before the patch in patches/ that padding stayed, + * grew with every later adjustment and left a blank under the chat until it was reopened. + */ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium } from "playwright-core"; +import { chromePath } from "./chrome-path.ts"; +import type {} from "./chat-scroll-padding-fixture.tsx"; + +const STEPS = 150; +const root = mkdtempSync(join(tmpdir(), "herdr-chat-padding-")); +let browser: Awaited> | undefined; +let server: ReturnType | undefined; +try { + const build = await Bun.build({ + entrypoints: ["scripts/chat-scroll-padding-fixture.tsx"], outdir: root, target: "browser", + define: { "process.env.NODE_ENV": '"development"' }, + }); + assert.ok(build.success, String(build.logs)); + server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + const path = new URL(request.url).pathname; + if (path === "/") return new Response('
', { headers: { "content-type": "text/html" } }); + // a path the build did not write (the browser's /favicon.ico) is a 404: a Bun.file of a + // missing path fails the response with ENOENT, and that exits the script with 1 + const file = Bun.file(join(root, path.slice(1))); + return await file.exists() ? new Response(file) : new Response(null, { status: 404 }); + } }); + browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); + // a Retina screen: scroll offsets and row edges fall between whole pixels + const page = await (await browser.newContext({ deviceScaleFactor: 2, viewport: { width: 800, height: 700 } })).newPage(); + page.setDefaultTimeout(15_000); + await page.goto(`http://127.0.0.1:${server.port}/`); + await page.waitForSelector(".padding-qa-footer"); + await page.evaluate(() => { + const content = document.querySelector(".padding-qa-content"); + if (!content) throw new Error("no list content"); + const seen: string[] = []; + (window as unknown as { paddings: string[] }).paddings = seen; + new MutationObserver(() => { if (content.style.paddingBottom) seen.push(content.style.paddingBottom); }).observe(content, { attributes: true, attributeFilter: ["style"] }); + }); + const frames = () => page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + for (let step = 0; step < STEPS; step++) { + // scroll to a spot near the end, then change the header above it and the rows below it + await page.evaluate((step) => { + const scroller = document.querySelector(".padding-qa-scroller"); + if (!scroller) throw new Error("no list scroller"); + scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight - ((step * 13) % 17) * 23.3; + }, step); + await frames(); + await page.evaluate((step) => { + window.paddingQa.header(20 + ((step * 37) % 11) * 97.13); + if (step % 3 === 0) window.paddingQa.add(41.3 + (step % 7) * 23.71); + if (step % 5 === 0) window.paddingQa.resize(-((step % 4) * 61.7)); + }, step); + await frames(); + await frames(); + const left = await page.evaluate(() => { + const content = document.querySelector(".padding-qa-content"); + const scroller = document.querySelector(".padding-qa-scroller"); + const footer = document.querySelector(".padding-qa-footer"); + if (!content || !scroller || !footer) throw new Error("list parts missing"); + return { + padding: content.style.paddingBottom, + // space the scroller holds past the footer + afterFooter: Math.round(scroller.scrollHeight - scroller.scrollTop - (footer.getBoundingClientRect().bottom - scroller.getBoundingClientRect().top)), + }; + }); + assert.equal(left.padding, "", `step ${step}: the list's temporary end padding stayed (${JSON.stringify(left)})`); + assert.ok(left.afterFooter <= 1, `step ${step}: blank space under the footer (${JSON.stringify(left)})`); + } + // the path ran: at least one padding at Chromium's six-digit limit, which a longer fraction is rounded to + const paddings = await page.evaluate(() => (window as unknown as { paddings: string[] }).paddings); + assert.ok(paddings.some((value) => /\.\d{3}px$/.test(value)), `no rounded padding was exercised: ${JSON.stringify(paddings)}`); + console.log(`ok: ${STEPS} adjustments, no padding left (${paddings.length} temporary paddings, e.g. ${paddings.find((value) => /\.\d{3}px$/.test(value))})`); +} finally { + await browser?.close(); + server?.stop(true); + rmSync(root, { recursive: true, force: true }); +} diff --git a/scripts/chat-selection-copy-regression.ts b/scripts/chat-selection-copy-regression.ts new file mode 100644 index 000000000..bdc239b13 --- /dev/null +++ b/scripts/chat-selection-copy-regression.ts @@ -0,0 +1,122 @@ +/** Real ChatView selection and browser clipboard over fictional conversation HTTP. */ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium, webkit, type Page } from "playwright-core"; + +const root = mkdtempSync(join(tmpdir(), "herdr-chat-selection-copy-")); +const reply = "Outside selection.\n\n- First item\n- Second item\n- Third item\n\nKeep `src/main.ts` and `worker` here.\n\nNext paragraph.\n\n```sh\n first\n\n second \n```\n\nOutside end."; +const expected = "- First item\n- Second item\n- Third item\n\nKeep `src/main.ts` and `worker` here.\n\nNext paragraph.\n\n```sh\n first\n\n second \n```"; +const safari = process.env.COPY_WEBKIT === "1"; + +async function readClipboard(page: Page): Promise { + const copied = safari ? null : await page.evaluate(() => navigator.clipboard.readText()); + // Read through native textarea paste too: WebKit has no clipboard-read permission. + await page.evaluate(() => { + const input = document.createElement("textarea"); + input.id = "clipboard-reader"; + document.body.append(input); + input.focus(); + }); + await page.keyboard.press("ControlOrMeta+v"); + const value = await page.locator("#clipboard-reader").inputValue(); + await page.locator("#clipboard-reader").evaluate((node) => node.remove()); + if (copied !== null) assert.equal(value, copied, "native paste preserves copied text/plain"); + return value; +} +try { + const build = await Bun.build({ entrypoints: ["scripts/chat-virtual-fixture.tsx"], outdir: root, target: "browser", define: { "process.env.NODE_ENV": '"development"' } }); + assert.ok(build.success, String(build.logs)); + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { + const path = new URL(request.url).pathname; + if (path === "/favicon.ico") return new Response(null, { status: 204 }); + if (path.endsWith("/pane/commands")) return Response.json({ commands: [] }); + if (path.endsWith("/pane/conversation")) return Response.json({ source: "claude-transcript", history_id: "copy-fixture", cursor: null, turns: [{ role: "assistant", ts: null, parts: [{ kind: "text", text: reply }] }] }); + if (path === "/") return new Response('
', { headers: { "content-type": "text/html" } }); + return new Response(Bun.file(join(root, path.slice(1)))); + } }); + try { + const browser = await (safari ? webkit : chromium).launch({ executablePath: safari ? undefined : process.env.CHROME_PATH, headless: true }); + try { + for (const width of [1440, 390]) { + const context = await browser.newContext({ viewport: { width, height: 900 }, hasTouch: width === 390, isMobile: width === 390, permissions: safari ? [] : ["clipboard-read", "clipboard-write"] }); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + await page.goto(`http://127.0.0.1:${server.port}/`); + await page.getByText("Outside end.", { exact: true }).waitFor(); + await page.locator(".markdown").evaluate((node) => { + const range = document.createRange(); + range.setStart(node.querySelector("li span")!.firstChild!, 0); + range.setEndAfter(node.querySelector(".markdown-code")!); + getSelection()!.removeAllRanges(); + getSelection()!.addRange(range); + }); + await page.keyboard.press("ControlOrMeta+c"); + const copied = await readClipboard(page); + assert.equal(copied, expected, "partial reply keeps list rows, paragraphs, inline code and fenced whitespace"); + assert.deepEqual(await page.locator(".markdown").evaluate((node) => ({ + item: getComputedStyle(node.querySelector("li")!).display, + paragraph: getComputedStyle(node.querySelector("p")!).display, + file: getComputedStyle(node.querySelector(".markdown-file")!).getPropertyValue("-webkit-user-select"), + })), { item: "list-item", paragraph: "block", file: "text" }, "touch/WebKit CSS keeps semantic blocks and selectable file code"); + await page.locator(".markdown").evaluate((node) => { + const items = node.querySelectorAll("li span"); + const range = document.createRange(); + range.setStart(items[0]!.firstChild!, 2); + range.setEnd(items[2]!.firstChild!, 3); + getSelection()!.removeAllRanges(); + getSelection()!.addRange(range); + }); + await page.keyboard.press("ControlOrMeta+c"); + assert.equal(await readClipboard(page), "- rst item\n- Second item\n- Thi", "partial list endpoints keep markers and rows"); + await page.locator(".markdown-file code").evaluate((node) => { + const range = document.createRange(); + range.setStart(node.firstChild!, 4); + range.setEnd(node.firstChild!, 8); + getSelection()!.removeAllRanges(); + getSelection()!.addRange(range); + }); + await page.keyboard.press("ControlOrMeta+c"); + assert.equal(await readClipboard(page), "`main`", "a selection inside a file-code button keeps only selected text"); + await page.locator(".markdown p code").filter({ hasText: "worker" }).evaluate((node) => { + const range = document.createRange(); + range.selectNodeContents(node); + getSelection()!.removeAllRanges(); + getSelection()!.addRange(range); + }); + await page.keyboard.press("ControlOrMeta+c"); + assert.equal(await readClipboard(page), "`worker`", "non-file inline command code is retained"); + await page.locator(".markdown").evaluate((node) => { + const paragraphs = node.querySelectorAll("p"); + const start = document.createTreeWalker(paragraphs[1]!, NodeFilter.SHOW_TEXT); + let last = start.nextNode()!; + while (start.nextNode()) last = start.currentNode; + const end = document.createTreeWalker(paragraphs[2]!, NodeFilter.SHOW_TEXT).nextNode()!; + const range = document.createRange(); + range.setStart(last, 1); + range.setEnd(end, 4); + getSelection()!.removeAllRanges(); + getSelection()!.addRange(range); + }); + await page.keyboard.press("ControlOrMeta+c"); + assert.equal(await readClipboard(page), "here.\n\nNext", "prose-only partial endpoints retain the paragraph break"); + await page.locator(".markdown pre").evaluate((node) => { + const range = document.createRange(); + range.selectNodeContents(node); + getSelection()!.removeAllRanges(); + getSelection()!.addRange(range); + }); + await page.keyboard.press("ControlOrMeta+c"); + assert.equal(await readClipboard(page), " first\n\n second ", "code-only selection keeps spaces and empty lines without fences"); + await page.evaluate(() => getSelection()!.removeAllRanges()); + await page.getByRole("button", { name: "Copy as markdown", exact: true }).click({ force: true }); + assert.equal(await readClipboard(page), reply, "per-message Markdown Copy is unchanged"); + await page.getByRole("button", { name: "Copy as plain text", exact: true }).click({ force: true }); + assert.equal(await readClipboard(page), "Outside selection.\n\n- First item\n- Second item\n- Third item\n\nKeep src/main.ts and worker here.\n\nNext paragraph.\n\n first\n\n second \n\n\nOutside end.", "per-message Plain text Copy keeps text and line breaks"); + await context.close(); + console.log(`PASS ${width}: partial rendered reply keeps list rows, paragraphs, inline code and fenced whitespace through real copy`); + } + } finally { await browser.close(); } + } finally { await server.stop(true); } +} finally { rmSync(root, { recursive: true, force: true }); } diff --git a/scripts/chat-size-regression.ts b/scripts/chat-size-regression.ts index 34a56720c..ead00ab55 100644 --- a/scripts/chat-size-regression.ts +++ b/scripts/chat-size-regression.ts @@ -7,7 +7,8 @@ import type { Machine } from "../shared/machines.ts"; import { chromium } from "playwright-core"; import { createServer } from "../server/index.ts"; import { PtySession } from "../server/pty/session.ts"; -import { herdrRpc, herdrSocketPath, type WorkspaceCreateResult, paneRead, paneSendKeys, paneSendText, workspaceClose, workspaceCreate } from "../server/herdr/client.ts"; +import { herdrRpc, herdrSocketPath, type WorkspaceCreateResult, paneRead, paneSendKeys, paneSendText, tabCreate, workspaceClose, workspaceCreate } from "../server/herdr/client.ts"; +import { chromePath } from "./chrome-path.ts"; /** how long a resize that should not happen gets to show up */ const NO_RESIZE_WAIT_MS = 400; @@ -142,7 +143,9 @@ export async function checkChatKeepsTerminalSize(browser: Browser, origin: strin // an agent pane opens in the chat lens on a phone; the shell under it answers `stty size` await herdrRpc("pane.report_agent", { pane_id: paneId, source: "manual", agent: "claude", state: "idle" }); await agentListed(origin, paneId); - const size = shellSize(paneId); + // This scenario resizes the pty while probing it. Read each shell answer from a file: + // reflow can erase a completed answer from pane.read's screen snapshot. + const size = shellSize(paneId, cwd); const open = (options: Parameters[0], settings: object) => openRecording(browser, contexts, origin, paneId, options, settings); const sent = (page: Page) => page.evaluate(() => (window as unknown as { frames_: { dir: string; type: string; keep_size?: boolean }[] }).frames_.filter((f) => f.dir === "out" && (f.type === "attach" || f.type === "resize"))); @@ -170,6 +173,55 @@ export async function checkChatKeepsTerminalSize(browser: Browser, origin: strin assert.ok(Number(phoneSize.split(" ")[1]) < Number(desktopSize.split(" ")[1]), `phone ${phoneSize} narrower than desktop ${desktopSize}`); console.log(`PASS the phone's terminal lens fits the grid to ${phoneSize}`); + // Regression (#1085): a window blur is not a hidden terminal. Its grid must remain + // eligible when the phone leaves the terminal, without changing blur's reconnect policy. + const blurMark = (await framesOf(desktop)).length; + await phone.bringToFront(); + await desktop.evaluate(() => { + if (document.visibilityState !== "visible") throw new Error("desktop must stay visible"); + // Headless contexts each retain focus; model the OS window blur without hiding the page. + Object.defineProperty(document, "hasFocus", { configurable: true, value: () => false }); + window.dispatchEvent(new Event("blur")); + }); + assert.equal((await framesOf(desktop)).slice(blurMark) + .filter((f) => f.dir === "out" && f.type === "release-size").length, 0, + "a visible desktop blur does not release its grid"); + await desktop.evaluate(([rows, cols]) => { + const socket = (window as unknown as { socket_: WebSocket }).socket_; + let finish: (time: number) => void; + // A deadline resolves to Infinity so a late delivery fails without an unhandled rejection. + const restored = new Promise((resolve) => { finish = resolve; }); + Object.assign(window, { gridRestored_: restored }); + const timer = window.setTimeout(() => { + socket.removeEventListener("message", receive); + finish(Infinity); + }, 5_000); + function receive(event: MessageEvent): void { + const frame = JSON.parse(String(event.data)); + if (frame.type !== "pane-geometry" || frame.rows !== rows || frame.cols !== cols) return; + window.clearTimeout(timer); + socket.removeEventListener("message", receive); + finish(Date.now()); + } + socket.addEventListener("message", receive); + }, desktopSize.split(" ").map(Number)); + const chatAt = Date.now(); + await phone.locator('button[title^="Chat transcript"]').tap(); + const restoredAt = await desktop.evaluate(() => (window as unknown as { gridRestored_: Promise }).gridRestored_); + assert.ok(restoredAt - chatAt <= 1_000, "phone Chat restores the visible desktop grid within 1 s"); + // pane-geometry announces the resize before the sidecar applies it to herdr's pty. + // Read shell replies within the same 1 s budget, rather than racing the sidecar once. + let restoredSize = ""; + while (Date.now() < chatAt + 1_000 && restoredSize !== desktopSize) restoredSize = await size(); + assert.equal(restoredSize, desktopSize, "the unfocused desktop's grid is restored in the real pty"); + assert.ok(Date.now() - chatAt <= 1_000, "the real pty restores the desktop grid within 1 s"); + console.log(`PASS phone Chat restores the visible unfocused desktop's ${desktopSize}`); + await phone.locator('button[title^="Live terminal"]').tap(); + let reclaimedSize = ""; + const reclaimDeadline = Date.now() + 10_000; + while (Date.now() < reclaimDeadline && reclaimedSize !== phoneSize) reclaimedSize = await size(); + assert.equal(reclaimedSize, phoneSize, "returning to the phone terminal still reclaims its grid"); + // the desktop's terminal lens ignored that resize: it drives the grid itself. Entering the chat // lens, its hidden screen takes the grid the pty has now, since what the chat reads there (a // masked prompt) is drawn for the phone's grid. xterm's DOM renderer keeps one element a row. @@ -366,18 +418,14 @@ export async function checkBackgroundTabKeepsTerminalSize(browser: Browser, orig assert.equal(await size(), phoneSize, "a background reconnect leaves the phone's grid"); console.log("PASS a desktop window reconnecting in the background does not take the pane again"); - // it reloads in the background: the first attach adopts the grid, and so does the chosen font - // loading after it; a moment later it lets go of the pane again + // Even a safe join is watched: the client cannot know whether the phone's attach still exists. await desktop.context().addInitScript(() => Object.defineProperty(document, "hasFocus", { configurable: true, value: () => false })); await desktop.reload(); await desktop.locator(".conn-live").waitFor(); - await attached(desktop); - await Bun.sleep(NO_RESIZE_WAIT_MS); - assert.deepEqual(await sizing(desktop), [{ dir: "out", type: "attach", keep_size: true }], "a background reload attaches without resizing"); - assert.equal(await size(), phoneSize, "a background reload leaves the phone's grid"); - await desktop.waitForFunction(() => (window as unknown as { frames_: { dir: string; type: string }[] }).frames_.some((f) => f.dir === "out" && f.type === "detach"), undefined, { timeout: 10_000 }); await viewOnly(desktop).waitFor({ timeout: 10_000 }); - console.log("PASS a desktop window reloading in the background leaves the shared grid, then lets go of the pane"); + assert.deepEqual(await sizing(desktop), [], "a background reload watches without attaching"); + assert.equal(await size(), phoneSize, "a background reload leaves the phone's grid"); + console.log("PASS a desktop window reloading in the background watches without joining the shared attach"); // the phone leaves too: no tab uses the pane, and the bridge lets go of herdr's attach, // which is what gives the pane back to herdr's own TUI @@ -399,7 +447,10 @@ export async function checkBackgroundTabKeepsTerminalSize(browser: Browser, orig // the pane closes in herdr and the window moves on to the focused one in the background: having // let go, it attaches nothing until the user is back, and that pane keeps the size it has + await paneSendText(nextPane, "stty cols 132 rows 38"); + await paneSendKeys(nextPane, ["Enter"]); const nextBefore = await nextSize(); + assert.equal(nextBefore, "38 132"); assert.notEqual(nextBefore, desktopSize, "the next pane starts at a size the window would change"); const switching = (await sizing(desktop)).length; const watchesBeforeSwitch = (await framesOf(desktop)).filter((frame) => frame.dir === "out" && frame.type === "watch").length; @@ -419,7 +470,7 @@ export async function checkBackgroundTabKeepsTerminalSize(browser: Browser, orig delete (document as unknown as { hasFocus?: unknown }).hasFocus; window.dispatchEvent(new Event("focus")); }); - await attached(desktop, 2); + await attached(desktop); assert.deepEqual((await sizing(desktop)).slice(returning).map((f) => ({ type: f.type, keep_size: f.keep_size ?? false })), [{ type: "attach", keep_size: false }], "the focused window attaches at its own size"); const back = Date.now() + 10_000; let current = nextBefore; @@ -442,12 +493,16 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str const failures: string[] = []; try { const panes: string[] = []; + const tabs = new Map(); + const terminals = new Map(); let heldTerminal: string | null = null; for (const name of ["first", "next", "held"]) { const cwd = join(root, name); mkdirSync(cwd); const created = await workspaceCreate({ cwd, label: `herdr-web-ui-test-inactive-${name}` }); panes.push(created.root_pane.pane_id); + terminals.set(created.root_pane.pane_id, created.root_pane.terminal_id); + tabs.set(created.root_pane.pane_id, created.root_pane.tab_id); if (name === "held") heldTerminal = created.root_pane.terminal_id; workspaces.push(created.workspace.workspace_id); } @@ -468,15 +523,27 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str }; const sent = async (page: Page, start = 0) => (await framesOf(page)).slice(start) .filter((f) => f.dir === "out" && (f.type === "attach" || f.type === "resize")); - const scenarios = ["mount", "switch", "resume", "toggles", "held", "queue-switch", "queue-inflight", "writes"] as const; + const scenarios = ["mount", "mount-optin", "hidden-mount", "legacy-mount", "mirrored-mount", "reconnect", "hidden-reconnect", "legacy-reconnect", "key-resume", "switch", "background-cache", "mirrored-cache", "resume", "toggles", "held", "queue-switch", "queue-inflight", "writes", "embedded"] as const; assert.ok(!process.env.CHAT_SIZE_CASE || scenarios.some((scenario) => scenario === process.env.CHAT_SIZE_CASE), "unknown CHAT_SIZE_CASE"); for (const scenario of scenarios) { if (process.env.CHAT_SIZE_CASE && process.env.CHAT_SIZE_CASE !== scenario) continue; + if (scenario === "embedded") continue; let holder: PtySession | undefined; let page: Page | undefined; const heldPane = panes[2]!; try { const queueScenario = scenario.startsWith("queue-"); + const fresh = ["mount", "mount-optin", "hidden-mount", "legacy-mount", "mirrored-mount", "key-resume"].includes(scenario); + const reconnect = scenario.endsWith("reconnect"); + const legacy = scenario.startsWith("legacy-"); + const watchFirst = fresh && !legacy && scenario !== "mirrored-mount"; + const firstTerminal = terminals.get(first)!; + if (watchFirst) { + // A deliberately non-layout size exposes #902: a keep_size sidecar spawn would replace it. + await paneSendText(first, "stty cols 140 rows 40"); + await paneSendKeys(first, ["Enter"]); + assert.equal(await shellSize(first)(), "40 140"); + } if (queueScenario) { await herdrRpc("pane.report_agent", { pane_id: first, source: "manual", agent: "claude", state: "working" }); await agentListed(origin, first); @@ -501,13 +568,42 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str }; listeners.add(check); check(); }); - const routed = queueScenario || scenario === "writes"; + const routed = queueScenario || scenario === "writes" || scenario === "background-cache" || scenario === "mirrored-cache" || legacy; const recorded = async (): Promise => { if (routed) return [...wire]; assert.ok(page); return framesOf(page); }; const setup = async (recording: Page): Promise => { + if (scenario === "hidden-mount") await recording.route("**/api/machines", async (route) => { + const response = await route.fetch(); + // App deliberately does not load while hidden. Hide after loading starts, but before + // the roster response can mount a pane; hasFocus alone must not permit an attach. + await recording.evaluate(() => { + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "hidden" }); + Object.defineProperty(document, "hasFocus", { configurable: true, value: () => true }); + }); + await route.fulfill({ response }); + }); + if (scenario === "mirrored-mount") await recording.route("**/api/machines", async (route) => { + const response = await route.fetch(); + const roster: { machines: Machine[] } = await response.json(); + for (const machine of roster.machines) if (machine.herdr) { + machine.herdr.terminal_attach = false; + machine.herdr.terminal_mirror = true; + } + await route.fulfill({ response, json: roster }); + }); + if (scenario === "mirrored-mount") await recording.route("**/api/health", async (route) => { + const response = await route.fetch(); + const health = await response.json(); + health.herdr.terminal_attach = false; + health.herdr.terminal_mirror = true; + // The roster mounts the pane before health tells the client that it is mirrored. + await recording.locator(`.terminal-stack[data-pane-owner="${first}"]`).waitFor(); + await route.fulfill({ response, json: health }); + }); + if (!routed) return; await recording.routeWebSocket(/\/ws(?:\?|$)/, (socket) => { injectOutput = () => { for (const data of ["\x1b[2J\x1b[H", "STALE-SUBSCRIPTION"]) { @@ -517,8 +613,17 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str const upstream = socket.connectToServer(); upstream.onMessage((raw) => { const frame = JSON.parse(String(raw)); + if (legacy && frame.type === "snapshot") { + frame.features = frame.features.filter((feature: string) => feature !== "watch"); + raw = JSON.stringify(frame); + } if (frame.type === "submit-result") acceptedQueue = Boolean(frame.ok && frame.pending); record(raw, "in"); + if (scenario === "mirrored-cache" && frame.type === "input-ready" && frame.pane_id === first) { + const geometry = JSON.stringify({ type: "pane-geometry", pane_id: first, cols: 80, rows: 24, fixed: true }); + record(geometry, "in"); + socket.send(geometry); + } if (holdOutput && frame.type === "pty-data") heldOutput.push(() => socket.send(raw)); else socket.send(raw); }); @@ -548,8 +653,9 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str try { await ready.promise; } finally { clearTimeout(deadline); } } page = await openRecording(browser, contexts, origin, scenario === "held" ? heldPane : first, - { viewport: { width: 1000, height: 700 } }, { language: "en", defaultView: queueScenario ? "chat" : "terminal", releasePaneAway: true }, - scenario === "mount", queueScenario || scenario === "writes" ? setup : undefined); + { viewport: { width: 1000, height: 700 } }, { language: "en", defaultView: queueScenario ? "chat" : "terminal", + releasePaneAway: scenario === "mount-optin" || (!fresh && !reconnect && scenario !== "switch") }, + fresh, setup); if (scenario === "held") { await page.getByText("Another app has this pane open.", { exact: false }).waitFor(); await pauseClock(page); @@ -579,7 +685,77 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str console.log("PASS inactive attach lifecycle: held"); continue; } + if (watchFirst) { + await page.locator(".terminal-banner", { hasText: "View only while you use another window" }).waitFor(); + await page.waitForFunction(() => (window as unknown as { frames_: Frame[] }).frames_.some((frame) => frame.type === "watch-data")); + assert.deepEqual(await sent(page), [], "an out-of-use mount watches without ever acquiring an attach"); + assert.equal(attachRunning(firstTerminal), false, "watching must not start a sidecar"); + assert.equal(await shellSize(first)(), "40 140", "watching preserves the real size, not the layout rectangle"); + if (scenario === "key-resume") { + await page.locator(".xterm-helper-textarea").dispatchEvent("keydown", { key: "Shift", code: "ShiftLeft", bubbles: true }); + await attached(page); + } else { + // Reload while still away, including when hidden but hasFocus() returns true. + await page.reload(); + await page.locator(".terminal-banner", { hasText: "View only while you use another window" }).waitFor(); + await page.waitForFunction(() => (window as unknown as { frames_: Frame[] }).frames_.some((frame) => frame.type === "watch-data")); + assert.deepEqual(await sent(page), [], "a background reload never acquires an attach"); + assert.equal(await shellSize(first)(), "40 140"); + await page.evaluate(() => { + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" }); + Object.defineProperty(document, "hasFocus", { configurable: true, value: () => true }); + window.dispatchEvent(new Event("focus")); + }); + await attached(page); + } + assert.equal((await sent(page)).filter((frame) => frame.type === "attach").length, 1, "user activity acquires one attach"); + console.log(`PASS inactive attach lifecycle: ${scenario}`); + continue; + } await (routed ? waitWire("in", "input-ready") : attached(page)); + if (fresh) { + assert.deepEqual(await sent(page), [{ dir: "out", type: "attach", keep_size: true }], "legacy and mirrored servers keep their initial attach"); + assert.equal((await recorded()).filter((frame) => frame.dir === "out" && frame.type === "watch").length, 0); + console.log(`PASS inactive attach lifecycle: ${scenario}`); + continue; + } + if (reconnect) { + // Keep the ordinary attach with the setting off, then lose the connection while away. + await page.evaluate((hidden) => { + Object.defineProperty(document, "hasFocus", { configurable: true, value: () => false }); + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => hidden ? "hidden" : "visible" }); + window.dispatchEvent(new Event("blur")); + }, scenario === "hidden-reconnect"); + const before = (await recorded()).length; + await page.evaluate(() => (window as unknown as { socket_: WebSocket }).socket_.close()); + if (legacy) { + await waitWire("in", "input-ready", 2); + assert.deepEqual((await recorded()).slice(before).filter((frame) => frame.dir === "out" && frame.type === "attach"), + [{ dir: "out", type: "attach", keep_size: true }], "a legacy reconnect keeps today's attach behavior"); + } else { + await page.locator(".terminal-banner", { hasText: "View only while you use another window" }).waitFor(); + await page.waitForFunction(() => (window as unknown as { frames_: Frame[] }).frames_.some((frame) => frame.type === "watch-data")); + assert.deepEqual(await sent(page, before), [], "a background reconnect watches before any attach replay"); + } + console.log(`PASS inactive attach lifecycle: ${scenario}`); + continue; + } + if (scenario === "mirrored-cache") { + // Given a mounted fixed-grid pane (the mirror's geometry contract), keep its subscription. + await waitWire("in", "pane-geometry"); + await pauseClock(page); + const extra = await tabCreate({ workspaceId: workspaces[0]!, label: "Mirror cache" }); + const before = wire.length; + // When it becomes cached, presentation effects must not release the mirror. + await page.locator(`[role="tab"][data-tab-id="${extra.tab.tab_id}"]`).evaluate((button: HTMLElement) => button.click()); + await page.locator(`.pane-frame.is-hidden[data-layout-pane="${first}"]`).waitFor({ state: "attached" }); + await page.clock.runFor(0); + await waitWire("in", "input-ready", 2); + // Then the other pane is attached but no detach was sent for the cached mirror. + assert.deepEqual(detachedPanes(wire.slice(before)), [], "caching a mirrored pane never releases it"); + console.log("PASS inactive attach lifecycle: mirrored-cache"); + continue; + } if (queueScenario) { await page.locator('.composer-status[data-status="working"]').waitFor(); await page.locator(".composer-text").fill("lifecycle follow-up"); @@ -588,11 +764,7 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str await waitWire("out", "submit"); if (scenario === "queue-switch") await page.locator('.pending-message[data-state="queued"]').waitFor(); } - if (scenario === "mount") { - // No blur or visibility transition occurs after this out-of-use mount. - await waitDetach(page); - assert.deepEqual(await sent(page), [{ dir: "out", type: "attach", keep_size: true }]); - } else { + { // Freeze timers after the real first attach; only the release delay is advanced below. await pauseClock(page); if (scenario === "writes") { @@ -609,27 +781,27 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str "a queued or unacknowledged follow-up holds its originating attach"); if (scenario === "queue-switch") { await page.locator(`.pane-select[title^="${next} — "]`).evaluate((button: HTMLElement) => button.click()); - await waitWire("out", "attach", 2); + await waitWire("out", "watch"); await page.locator(`.terminal-stack[data-pane-owner="${next}"]`).waitFor({ state: "attached" }); - await waitWire("out", "detach", 1); await page.clock.runFor(0); - assert.deepEqual(detachedPanes(await recorded()), [first], - "navigation detaches pane A while pane B starts its own release delay"); + assert.deepEqual(detachedPanes(await recorded()), [], + "navigation keeps pane A attached while its queued message waits"); await assertSharedConnection(page); - await waitWire("in", "input-ready", 2); - await page.clock.runFor(900); + assert.equal(wire.filter((frame) => frame.dir === "out" && frame.type === "attach").length, 1, + "pane B watches immediately; pane A's queue is not inherited"); await page.evaluate(() => { Object.defineProperty(document, "hasFocus", { configurable: true, value: () => true }); window.dispatchEvent(new Event("focus")); Object.defineProperty(document, "hasFocus", { configurable: true, value: () => false }); window.dispatchEvent(new Event("blur")); }); + await waitWire("in", "input-ready", 2); await page.clock.runFor(100); - assert.deepEqual(detachedPanes(await recorded()), [first], - "pane A's orphaned timer cannot shorten pane B's new delay"); + assert.deepEqual(detachedPanes(await recorded()), [], + "pane A's retained attach cannot shorten pane B's new delay"); await page.clock.runFor(900); - await waitWire("out", "detach", 2); - assert.deepEqual(detachedPanes(await recorded()), [first, next], "pane B releases only after its full new delay"); + await waitWire("out", "detach", 1); + assert.deepEqual(detachedPanes(await recorded()), [next], "pane B releases only after its full new delay; pane A stays for its queue"); await assertSharedConnection(page); } else { assert.ok(releaseSubmit, "the explicit Send is waiting at the server boundary"); @@ -644,6 +816,44 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str } await waitWire("out", "detach"); } + } else if (scenario === "background-cache") { + // Given an away-released pane, navigation must not change its release reason. + await page.clock.runFor(1000); + await waitWire("out", "watch"); + const viewOnly = page.locator(".pane-frame.is-current .terminal-banner", { hasText: "View only while you use another window" }); + await viewOnly.waitFor(); + const extra = await tabCreate({ workspaceId: workspaces[0]!, label: "Background cache" }); + tabs.set(extra.root_pane.pane_id, extra.tab.tab_id); + const pick = async (pane: string): Promise => { + assert.ok(page); + const tab = tabs.get(pane); + assert.ok(tab); + await page.locator(`[role="tab"][data-tab-id="${tab}"]`).evaluate((button: HTMLElement) => button.click()); + await page.locator(`.pane-frame.is-current[data-layout-pane="${pane}"]`).waitFor(); + await page.clock.runFor(0); + }; + const before = (await recorded()).length; + // When A -> B -> A happens without window focus, both panes remain observers. + await pick(extra.root_pane.pane_id); + await waitWire("out", "unwatch"); + await waitWire("out", "watch", 2); + await pick(first); + await waitWire("out", "unwatch", 2); + assert.equal(await page.evaluate(() => document.hasFocus()), false); + assert.deepEqual((await recorded()).slice(before).filter((frame) => frame.dir === "out" && (frame.type === "attach" || frame.type === "resize")), [], + "an away-released pane returned from cache in the background stays watch-only"); + await waitWire("out", "watch", 3); + await viewOnly.waitFor(); + // Then real focus resumes the presented pane, not its cached sibling. + const returning = (await recorded()).length; + await page.evaluate(() => { + Object.defineProperty(document, "hasFocus", { configurable: true, value: () => true }); + window.dispatchEvent(new Event("focus")); + }); + await waitWire("in", "input-ready", 2); + assert.equal((await recorded()).slice(returning).filter((frame) => frame.dir === "out" && frame.type === "attach").length, 1, + "focus attaches only the presented pane"); + await viewOnly.waitFor({ state: "hidden" }); } else if (scenario === "writes") { await page.clock.runFor(999); await page.evaluate(() => { @@ -681,14 +891,13 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str const before = (await framesOf(page)).length; // Programmatic click deliberately does not focus the window. await page.locator(`.pane-select[title^="${next} — "]`).evaluate((button: HTMLElement) => button.click()); - await attached(page, 2); - assert.deepEqual(await sent(page, before), [{ dir: "out", type: "attach", keep_size: true }]); + await page.locator(".terminal-banner", { hasText: "View only while you use another window" }).waitFor(); + assert.deepEqual(await sent(page, before), []); await waitPaneDetach(page, first); assert.deepEqual(detachedPanes(await framesOf(page)), [first], "navigation releases only the old pane's lease"); await assertSharedConnection(page); await page.clock.runFor(1000); - await waitDetach(page, 2); // the new pane still releases after its own full delay - assert.deepEqual(detachedPanes(await framesOf(page)), [first, next], "the new pane releases its own lease after the delay"); + assert.deepEqual(detachedPanes(await framesOf(page)), [first], "the new pane never acquired a lease to release"); await assertSharedConnection(page); } else if (scenario === "resume") { await page.clock.runFor(1000); @@ -750,6 +959,47 @@ export async function checkInactiveAttachLifecycle(browser: Browser, origin: str } } assert.deepEqual(failures, [], "inactive attach lifecycle scenarios"); + if (!process.env.CHAT_SIZE_CASE || process.env.CHAT_SIZE_CASE === "embedded") { + // Exercise the demo's embedded client without weakening the real app's anti-framing CSP. + const context = await browser.newContext(); + contexts.push(context); + // An intercepted iframe document has no network address space until Chrome grants it. + await context.grantPermissions(["local-network-access"], { origin }); + await context.addInitScript(() => { + localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", defaultView: "terminal" })); + Object.defineProperty(document, "hasFocus", { configurable: true, value: () => false }); + }); + const page = await context.newPage(); + const wire: string[] = []; + let attachedFrame: () => void = () => {}; + const inputReady = new Promise((resolve) => { attachedFrame = resolve; }); + page.on("websocket", (socket) => { + socket.on("framesent", ({ payload }) => wire.push(JSON.parse(String(payload)).type)); + socket.on("framereceived", ({ payload }) => { + if (JSON.parse(String(payload)).type === "input-ready") attachedFrame(); + }); + }); + await page.route("**/embed-test", (route) => route.fulfill({ contentType: "text/html", + body: `` })); + await page.route(`${origin}/?pane=${encodeURIComponent(first)}`, async (route) => { + const response = await route.fetch(); + const headers = response.headers(); + const policy = headers["content-security-policy"]; + assert.ok(policy); + await route.fulfill({ response, headers: { ...headers, + "content-security-policy": policy.replace("frame-ancestors 'none'", "frame-ancestors 'self'") } }); + }); + await page.goto(`${origin}/embed-test`); + let timer: ReturnType | undefined; + try { + await Promise.race([inputReady, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("embedded pane never attached")), 15_000); + })]); + assert.equal(wire.filter((type) => type === "attach").length, 1); + assert.equal(wire.includes("watch"), false, "the embedded page keeps its attach without document focus"); + console.log("PASS inactive attach lifecycle: embedded"); + } finally { clearTimeout(timer); await context.close(); } + } } finally { for (const context of contexts) await context.close().catch(() => undefined); for (const id of workspaces) await workspaceClose(id).catch(() => undefined); @@ -764,9 +1014,10 @@ if (import.meta.main) { const server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: join(root, "state") }); let browser: Browser | undefined; try { - browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox", "--accept-lang=en-US"] }); + browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox", "--accept-lang=en-US"] }); const origin = `http://127.0.0.1:${server.port}`; - if (process.env.CHAT_SIZE_CASE) await checkInactiveAttachLifecycle(browser, origin); + if (process.env.CHAT_SIZE_CASE === "reclaim") await checkFocusedWindowReclaimsTerminalSize(browser, origin); + else if (process.env.CHAT_SIZE_CASE) await checkInactiveAttachLifecycle(browser, origin); else { await checkChatKeepsTerminalSize(browser, origin); await checkPaneSwitchKeepsTerminalSize(browser, origin); @@ -816,7 +1067,7 @@ export async function checkFocusedWindowReclaimsTerminalSize(browser: Browser, o await attached(desktop); const desktopSize = await askedGrid(desktop); await sizeBecomes(desktopSize, "the desktop took the pane at its own size"); - const phone = await openRecording(browser, contexts, origin, paneId, { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }, settings, false, inFront); + const phone = await openRecording(browser, contexts, origin, paneId, { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }, { ...settings, terminalInputMode: "line" }, false, inFront); await attached(phone); const phoneSize = await askedGrid(phone); await sizeBecomes(phoneSize, "the phone took the pane at its own size"); @@ -834,6 +1085,56 @@ export async function checkFocusedWindowReclaimsTerminalSize(browser: Browser, o await desktop.keyboard.press("Shift"); await sizeBecomes(desktopSize, "a key in the desktop terminal"); console.log("PASS a window that kept its focus takes the pane back on a click or a key in its terminal"); + + // The phone's own input sits outside its grid: the input line, its Send and Enter and the key + // bar. Each takes the pane back to the phone's grid, and the resize goes out before what it sends. + const [phoneRows, phoneCols] = phoneSize.split(" ").map(Number); + const outFrom = async (page: Page, from: number) => (await framesOf(page)).slice(from).filter((f) => f.dir === "out"); + const resizedBefore = async (what: string, from: number, sends: readonly string[]) => { + const deadline = Date.now() + 10_000; + let out: Frame[] = []; + while (Date.now() < deadline) { + out = await outFrom(phone, from); + if (out.some((f) => sends.includes(f.type))) break; + await Bun.sleep(50); + } + const sent = out.findIndex((f) => sends.includes(f.type)); + assert.ok(sent >= 0, `${what}: the phone sent none of ${sends.join("/")}: ${JSON.stringify(out)}`); + const resized = out.findIndex((f) => f.type === "resize" && f.cols === phoneCols && f.rows === phoneRows); + assert.ok(resized >= 0 && resized < sent, `${what}: the phone's own grid goes out before its input: ${JSON.stringify(out)}`); + await sizeBecomes(phoneSize, what); + }; + // focus into the line: a tap there is the user on the phone, before a key is typed + let mark = (await framesOf(phone)).length; + await phone.locator(".terminal-input-text").tap(); + const focusDeadline = Date.now() + 10_000; + while (Date.now() < focusDeadline) { + if ((await outFrom(phone, mark)).some((f) => f.type === "resize")) break; + await Bun.sleep(50); + } + assert.ok((await outFrom(phone, mark)).some((f) => f.type === "resize" && f.cols === phoneCols && f.rows === phoneRows), "a tap into the input line takes the pane back to the phone's grid"); + await sizeBecomes(phoneSize, "a tap into the input line"); + // the line keeps its focus from here on: no focus event helps the steps below + await desktop.keyboard.press("Shift"); + await sizeBecomes(desktopSize, "a key in the desktop terminal, the phone's line focused"); + await phone.keyboard.type("true"); + mark = (await framesOf(phone)).length; + await phone.keyboard.press("Enter"); + // the line's Enter follows its text after the server's gap: the size is asked once the line is in + await phone.waitForFunction(() => (document.querySelector(".terminal-input-text") as HTMLTextAreaElement | null)?.value === "", undefined, { timeout: 10_000 }); + await resizedBefore("a line sent from the phone's input line", mark, ["submit", "input"]); + await desktop.keyboard.press("Shift"); + await sizeBecomes(desktopSize, "a key in the desktop terminal, before the key bar"); + mark = (await framesOf(phone)).length; + // Ctrl+C, not Esc: the shell would read Esc and the size command's first letter as one Meta key + await phone.locator(".key-bar").getByRole("button", { name: "Control C" }).tap(); + await resizedBefore("a key-bar tap on the phone", mark, ["input", "keys"]); + await desktop.keyboard.press("Shift"); + await sizeBecomes(desktopSize, "a key in the desktop terminal, before the empty line's Enter"); + mark = (await framesOf(phone)).length; + await phone.locator(".terminal-input-send").tap(); + await resizedBefore("Enter from the phone's empty input line", mark, ["input", "keys"]); + console.log("PASS the phone's input line, its Enter and the key bar take the pane back before they send"); } finally { for (const context of contexts) await context.close().catch(() => undefined); if (workspace) await workspaceClose(workspace).catch(() => undefined); diff --git a/scripts/chat-virtual-browser-qa.ts b/scripts/chat-virtual-browser-qa.ts new file mode 100644 index 000000000..8fff478b3 --- /dev/null +++ b/scripts/chat-virtual-browser-qa.ts @@ -0,0 +1,443 @@ +/** Real ChatView over controlled HTTP; no live panes or provider calls. */ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium, type Page } from "playwright-core"; +import type { ConversationTurn } from "../shared/protocol.ts"; +import type {} from "./chat-virtual-fixture.tsx"; + +const root = mkdtempSync(join(tmpdir(), "herdr-chat-virtual-")); +const evidence = process.env.UI_EVIDENCE_DIR; +if (evidence) mkdirSync(evidence, { recursive: true }); +let browser: Awaited> | undefined; +let server: ReturnType | undefined; +let activePage: Page | undefined; +let revision = 0; +let epoch = 1; +let holdOlder = false; +let failOlder = false; +let olderReads = 0; +let wholeReads = 0; +let searchPrefix = ""; +let newestStart = 0; +let beginsAtOrigin = false; +let slideReads = 0; +const WHOLE_OUTPUT = `FULL TOOL OUTPUT\n${"detail ".repeat(250)}\nFULL OUTPUT END`; +const olderEvents = new EventTarget(); +const olderReleases: Array<() => void> = []; +function turn(index: number, machine: string): ConversationTurn { + const text = `${index === 123 ? searchPrefix : ""}${machine} E${epoch} ROW-${index}\n\n${"Variable height paragraph. ".repeat(Math.abs(index) % 5 + 1)}${index === 999 ? `\n\nREVISION-${revision}` : ""}${index === 123 ? "\n\nNEEDLE one\n\nNEEDLE two" : ""}`; + const parts: ConversationTurn["parts"] = [{ kind: "text", text }]; + if (index === 497) parts.unshift({ kind: "tool", name: "Read", summary: "fixture tool", input: "{}", output: "preview only", output_ref: "fixture-output", output_size: WHOLE_OUTPUT.length }); + return { + role: index % 2 === 0 ? "user" : "assistant", + ts: index % 3 === 0 ? null : "2026-10-12T00:00:00.000Z", + parts, + }; +} +async function metrics(page: Page) { + return page.locator(".chat-view").evaluate((element) => { + const root = element.getBoundingClientRect(); + const rows = [...element.querySelectorAll("[data-chat-row]")]; + const visible = rows.filter((row) => { + const box = row.getBoundingClientRect(); + return box.bottom > root.top && box.top < root.bottom; + }).sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); + const first = visible[0]; + return { + mounted: rows.length, + keys: rows.map((row) => row.dataset.chatRow), + bottom: element.scrollHeight - element.clientHeight - element.scrollTop, + top: element.scrollTop, + anchor: first ? { key: first.dataset.chatRow, y: first.getBoundingClientRect().top, text: first.textContent } : null, + }; + }); +} +async function settleBottom(page: Page) { + await page.waitForFunction(() => { + const node = document.querySelector(".chat-view"); + return node && node.scrollHeight - node.clientHeight - node.scrollTop <= 1; + }); +} +async function update(page: Page) { + const response = page.waitForResponse((response) => response.url().includes("/conversation?") && !new URL(response.url()).searchParams.has("before")); + await page.evaluate(() => window.chatQa.refresh()); + await (await response).finished(); +} +async function shot(page: Page, name: string) { + if (evidence) await page.screenshot({ path: join(evidence, `chat-virtual-${name}.png`) }); +} +async function visibleMessage(page: Page, text: string) { + await page.waitForFunction((text) => { + const root = document.querySelector(".chat-view")?.getBoundingClientRect(); + return root && [...document.querySelectorAll(".chat-view p")].some((node) => { + const box = node.getBoundingClientRect(); + return node.textContent === text && box.top >= root.top - 1 && box.bottom <= root.bottom + 1; + }); + }, text); +} +async function selectionState(page: Page) { + return page.evaluate(() => { + const selection = getSelection(); + const row = (node: Node | null | undefined) => (node instanceof Element ? node : node?.parentElement)?.closest("[data-chat-row]")?.dataset.chatRow; + return { + text: selection?.toString(), + anchor: { row: row(selection?.anchorNode), text: selection?.anchorNode?.textContent?.slice(0, 80), offset: selection?.anchorOffset }, + focus: { row: row(selection?.focusNode), text: selection?.focusNode?.textContent?.slice(0, 80), offset: selection?.focusOffset }, + }; + }); +} +try { + const build = await Bun.build({ + entrypoints: ["scripts/chat-virtual-fixture.tsx"], outdir: root, target: "browser", + define: { "process.env.NODE_ENV": '"development"' }, + }); + assert.ok(build.success, String(build.logs)); + server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + const url = new URL(request.url); + if (url.pathname.endsWith("/pane/conversation/tool-output")) { wholeReads++; return new Response(WHOLE_OUTPUT); } + // the chat asks for the pane's slash commands to mark skill names in an answer: this pane has none + if (url.pathname.endsWith("/pane/commands")) return Response.json({ commands: [] }); + if (url.pathname.endsWith("/pane/conversation")) { + const machine = url.pathname.includes("/machines/remote/") ? "remote" : "local"; + const older = url.searchParams.has("before"); + if (url.searchParams.get("before") === "window:2") { + slideReads++; + assert.equal(url.searchParams.get("since"), beginsAtOrigin ? null : "older:0"); + return Response.json({ + source: "omp-transcript", history_id: `${machine}:${epoch}`, + cursor: beginsAtOrigin ? null : "older:0", + turns: [turn(0, machine), turn(1, machine)], + }); + } + const page = Number(url.searchParams.get("before")?.split(":")[1] ?? 0); + if (older) { + olderReads++; + if (failOlder) return Response.json({ error: { code: "unavailable", message: "fixture older read refused" } }, { status: 503 }); + } + if (older && holdOlder) await new Promise((resolve) => { + olderReleases.push(resolve); + olderEvents.dispatchEvent(new Event("requested")); + }); + return Response.json({ + source: "omp-transcript", history_id: `${machine}:${epoch}`, cursor: older ? page === 0 ? "older:1" : null : newestStart ? "window:2" : beginsAtOrigin ? null : "older:0", + turns: Array.from({ length: older ? 20 : 1000 }, (_, index) => turn(older ? index - (page + 1) * 20 : index + newestStart, machine)), + }); + } + if (url.pathname === "/") return new Response('Chat virtualization QA
', { headers: { "content-type": "text/html" } }); + return new Response(Bun.file(join(root, url.pathname.slice(1)))); + } }); + browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] }); + for (const [width, theme] of [[1440, "dark"], [1440, "light"], [390, "dark"], [390, "light"]] as const) { + revision = 0; epoch = 1; holdOlder = false; failOlder = false; olderReads = 0; wholeReads = 0; searchPrefix = ""; + newestStart = 0; beginsAtOrigin = false; slideReads = 0; + const context = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 900 }, colorScheme: theme, locale: "en-US" }); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await context.addInitScript((theme) => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", theme })), theme); + const page = await context.newPage(); + activePage = page; + page.setDefaultTimeout(15_000); + const errors: string[] = []; + page.on("pageerror", (error) => { errors.push(error.message); console.error(error.message); }); + await page.goto(`http://127.0.0.1:${server.port}/`); + await page.getByText("REVISION-0", { exact: true }).waitFor(); + await settleBottom(page); + const initial = await metrics(page); + assert.ok(initial.mounted > 0 && initial.mounted < 80, JSON.stringify(initial)); + assert.equal(new Set(initial.keys).size, initial.keys.length, "duplicate/null timestamps do not collide"); + await shot(page, `latest-${width}-${theme}`); + console.log(`PASS ${width} ${theme}: 1000 logical rows initially mount ${initial.mounted}, at latest`); + + for (revision = 1; revision <= 100; revision++) { + await update(page); + await page.getByText(`REVISION-${revision}`, { exact: true }).waitFor(); + await settleBottom(page); + } + console.log(`PASS ${width} ${theme}: 100 immutable streaming revisions retain bottom`); + + const height = width === 390 ? 844 : 900; + await page.evaluate((height) => window.chatQa.resize(height - 180), height); + await page.waitForFunction((height) => document.querySelector(".terminal-stack")?.getBoundingClientRect().height === height - 180, height); + await settleBottom(page); + await page.evaluate((height) => window.chatQa.resize(height), height); + await settleBottom(page); + assert.ok(await page.locator(".chat-view").evaluate((node) => node.scrollWidth <= node.clientWidth), "production terminal hierarchy has no horizontal overflow"); + + await page.evaluate(() => window.chatQa.page(-1)); + const away = await metrics(page); + assert.ok(away.bottom > 1 && away.anchor); + revision = 101; + await update(page); + await page.getByRole("button", { name: "New messages" }).waitFor(); + const after = await metrics(page); + const anchorY = await page.evaluate((key) => [...document.querySelectorAll("[data-chat-row]")].find((row) => row.dataset.chatRow === key)?.getBoundingClientRect().top, away.anchor.key); + assert.ok(anchorY !== undefined && Math.abs(anchorY - away.anchor.y) <= 1, JSON.stringify({ away, after, anchorY })); + await page.evaluate((height) => window.chatQa.resize(height - 180), height); + await page.waitForFunction((height) => document.querySelector(".terminal-stack")?.getBoundingClientRect().height === height - 180, height); + const resizedY = await page.evaluate((key) => [...document.querySelectorAll("[data-chat-row]")].find((row) => row.dataset.chatRow === key)?.getBoundingClientRect().top, away.anchor.key); + assert.ok(resizedY !== undefined && Math.abs(resizedY - away.anchor.y) <= 1, "composer-height change preserves the reading anchor"); + await page.evaluate((height) => window.chatQa.resize(height), height); + await page.getByRole("button", { name: "New messages" }).click(); + await settleBottom(page); + console.log(`PASS ${width} ${theme}: PageUp stays at its anchor through output and explicit latest rearms follow`); + + holdOlder = true; + const requested = new Promise((resolve, reject) => { + const received = () => { clearTimeout(timer); resolve(); }; + const timer = setTimeout(() => { olderEvents.removeEventListener("requested", received); reject(new Error("Older request missing")); }, 10_000); + olderEvents.addEventListener("requested", received, { once: true }); + }); + await page.locator(".chat-view").evaluate((node) => { node.scrollTop = 0; }); + await requested; + await page.getByText("local E1 ROW-0", { exact: true }).waitFor(); + const beforePrepend = await metrics(page); + assert.ok(beforePrepend.anchor); + holdOlder = false; + olderReleases.splice(0).forEach((release) => release()); + await page.getByRole("button", { name: "Earlier messages", exact: true }).waitFor(); + await page.waitForFunction(({ key, y }) => { + const row = [...document.querySelectorAll("[data-chat-row]")].find((row) => row.dataset.chatRow === key); + return row !== undefined && Math.abs(row.getBoundingClientRect().top - y) <= 1; + }, beforePrepend.anchor, { timeout: 2000 }); + const prependY = await page.evaluate((key) => [...document.querySelectorAll("[data-chat-row]")].find((row) => row.dataset.chatRow === key)?.getBoundingClientRect().top, beforePrepend.anchor.key); + assert.ok(prependY !== undefined && Math.abs(prependY - beforePrepend.anchor.y) <= 1, JSON.stringify({ beforePrepend, prependY, now: await metrics(page) })); + console.log(`PASS ${width} ${theme}: an accepted older page retains the same row and pixel offset`); + + const points = await page.evaluate(() => { + const paragraph = (text: string) => [...document.querySelectorAll(".chat-view p")].find((node) => node.textContent === text); + const edge = (paragraph: Element | undefined, last: boolean) => { + if (!paragraph) return null; + const walker = document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT); + const nodes: Node[] = []; + for (let node = walker.nextNode(); node; node = walker.nextNode()) if (node.textContent?.length) nodes.push(node); + return last ? nodes.at(-1) ?? null : nodes[0] ?? null; + }; + const start = edge(paragraph("local E1 ROW-0"), false); + const end = edge(paragraph("local E1 ROW-1"), true); + if (!start || !end) throw new Error("Selection paragraphs missing"); + const a = document.createRange(), b = document.createRange(); + a.setStart(start, 0); a.collapse(true); + b.setStart(end, end.textContent?.length ?? 0); b.collapse(true); + const first = a.getBoundingClientRect(), last = b.getBoundingClientRect(); + if (!first.height || !last.height) throw new Error(`Invisible drag endpoints: ${JSON.stringify({ first: first.toJSON(), last: last.toJSON(), start: start.textContent, end: end.textContent })}`); + const points = { a: { x: first.left + 1, y: first.top + first.height / 2 }, b: { x: last.left + 1, y: last.top + last.height / 2 } }; + const hit = (point: { x: number; y: number }) => { + const element = document.elementFromPoint(point.x, point.y); + return { element: element?.outerHTML.slice(0, 300), userSelect: element ? getComputedStyle(element).userSelect : null }; + }; + return { ...points, from: hit(points.a), to: hit(points.b) }; + }); + await page.mouse.move(points.a.x, points.a.y); + await page.mouse.down(); + const down = await selectionState(page); + await page.mouse.move(points.b.x, points.b.y, { steps: 8 }); + const moved = await selectionState(page); + await page.mouse.up(); + const selected = await page.evaluate(() => getSelection()?.toString() ?? ""); + assert.ok(selected.includes("ROW-0") && selected.includes("ROW-1") && selected.includes("\n\n"), `native selection includes both rows and blank lines: ${JSON.stringify({ selected, points, down, moved, up: await selectionState(page) })}`); + assert.deepEqual(selected.split(/\n+/).map((line) => line.trim()).filter(Boolean), ["local E1 ROW-0", "Variable height paragraph.", "local E1 ROW-1"], "the visual range contains exactly its logical rows, not pool-slot order"); + await page.waitForFunction(() => Number(document.querySelector(".chat-view")?.getAttribute("data-chat-pinned")) >= 2); + await page.locator(".chat-view").evaluate((node) => { node.scrollTop = node.scrollHeight; }); + await page.getByText("REVISION-101", { exact: true }).waitFor(); + await settleBottom(page); + revision = 102; + await update(page); + await page.getByText("REVISION-102", { exact: true }).waitFor(); + assert.equal(await page.evaluate(() => getSelection()?.toString()), selected, "offscreen selected rows survive immutable output"); + await page.keyboard.press(process.platform === "darwin" ? "Meta+c" : "Control+c"); + assert.equal(await page.evaluate(() => navigator.clipboard.readText()), selected, "real copy preserves the native multi-row selection"); + await page.evaluate(() => getSelection()?.removeAllRanges()); + await page.waitForFunction(() => document.querySelector(".chat-view")?.getAttribute("data-chat-pinned") === "0"); + assert.ok((await metrics(page)).mounted < 80); + console.log(`PASS ${width} ${theme}: native drag selection and real copy survive eviction and streaming; pins release`); + + await page.getByRole("button", { name: "Find in messages", exact: true }).click(); + const search = page.getByRole("searchbox", { name: "Find in messages" }); + await search.fill("local E1 ROW-123"); + const match = page.locator("[data-chat-match]"); + await match.filter({ hasText: "local E1 ROW-123" }).waitFor(); + await visibleMessage(page, "local E1 ROW-123"); + assert.ok((await metrics(page)).mounted < 80); + await search.fill("NEEDLE"); + await page.getByRole("button", { name: "Next match", exact: true }).click(); + assert.equal(await page.locator(".find-bar-count").textContent(), "2 of 2"); + // Enter on a button presses that button; only the field's Enter steps to the next match + await page.getByRole("button", { name: "Previous match", exact: true }).press("Enter"); + assert.equal(await page.locator(".find-bar-count").textContent(), "1 of 2", "Enter on Previous match goes back"); + await search.press("Enter"); + assert.equal(await page.locator(".find-bar-count").textContent(), "2 of 2", "Enter in the field goes forward"); + searchPrefix = "PREFIX-CHANGE\n\n"; + await update(page); + await page.getByText("PREFIX-CHANGE", { exact: true }).waitFor(); + assert.equal(await page.locator(".find-bar-count").textContent(), "2 of 2", "source offsets changing do not move the chosen occurrence to another match"); + await search.fill("local E1 ROW--40"); + await page.getByRole("button", { name: "Search earlier messages" }).click(); + await match.filter({ hasText: "local E1 ROW--40" }).waitFor(); + await visibleMessage(page, "local E1 ROW--40"); + await shot(page, `search-${width}-${theme}`); + await page.keyboard.press("Escape"); + const findButton = page.getByRole("button", { name: "Find in messages", exact: true }); + await findButton.waitFor(); + assert.ok(await findButton.evaluate((button) => document.activeElement === button), "closing search restores focus"); + await page.keyboard.press(process.platform === "darwin" ? "Meta+f" : "Control+f"); + await search.waitFor(); + await page.getByRole("button", { name: "Close search", exact: true }).press("Enter"); + await findButton.waitFor(); + await visibleMessage(page, "local E1 ROW--40"); + console.log(`PASS ${width} ${theme}: source Find reaches an offscreen row and an unloaded older page; shortcut and close focus work`); + + const boundarySelection = await page.evaluate(() => { + const header = document.querySelector(".chat-virtual-header"); + const first = [...document.querySelectorAll(".chat-view p")].find((node) => node.textContent === "local E1 ROW--40"); + if (!header || !first) throw new Error("Boundary selection elements missing"); + const range = document.createRange(); + range.setStart(header, 0); range.setEnd(first, first.childNodes.length); + getSelection()?.removeAllRanges(); getSelection()?.addRange(range); + return getSelection()?.toString(); + }); + assert.ok(boundarySelection?.includes("ROW--40")); + await page.waitForFunction(() => Number(document.querySelector(".chat-view")?.getAttribute("data-chat-pinned")) > 0); + await page.locator(".chat-view").evaluate((node) => { node.scrollTop = node.scrollHeight; }); + await settleBottom(page); + assert.equal(await page.evaluate(() => getSelection()?.toString()), boundarySelection, "selection with an endpoint outside a row survives scrolling"); + await page.evaluate(() => getSelection()?.removeAllRanges()); + await page.waitForFunction(() => document.querySelector(".chat-view")?.getAttribute("data-chat-pinned") === "0"); + + await findButton.click(); + await search.fill("local E1 ROW-497"); + await match.filter({ hasText: "local E1 ROW-497" }).waitFor(); + await page.keyboard.press("Escape"); + const work = page.locator("[data-chat-row]").filter({ hasText: "local E1 ROW-497" }); + await work.locator(".work-block-head").click(); + await work.locator(".work-row-head").click(); + const beforeOutput = await work.evaluate((row) => row.getBoundingClientRect().top); + await work.locator(".chat-tool-more").click(); + await work.locator(".chat-tool-io.is-whole").waitFor(); + assert.equal(await work.locator(".chat-tool-io.is-whole").textContent(), WHOLE_OUTPUT); + assert.ok(Math.abs(await work.evaluate((row) => row.getBoundingClientRect().top) - beforeOutput) <= 1, "opening output does not skip past the expanded work"); + assert.equal(wholeReads, 1); + const focusButton = work.locator(".work-row-head"); + await focusButton.focus(); + await page.waitForFunction(() => document.querySelector(".chat-view")?.getAttribute("data-chat-pinned") === "1"); + await page.locator(".chat-view").evaluate((node) => { node.scrollTop = node.scrollHeight; }); + await settleBottom(page); + assert.ok(await focusButton.evaluate((button) => button === document.activeElement), "offscreen focused button stays focused"); + await focusButton.evaluate((button) => button.blur()); + await page.waitForFunction(() => document.querySelector(".chat-view")?.getAttribute("data-chat-pinned") === "0"); + + // Make the viewport need more containers than the spare pool, so this checks + // restoration after actual eviction rather than a cached offscreen DOM node. + await page.setViewportSize({ width, height: 5000 }); + await page.evaluate(() => window.chatQa.resize(5000)); + await page.locator(".chat-view").evaluate((node) => { node.scrollTop = 0; }); + await page.waitForFunction(() => ![...document.querySelectorAll("[data-chat-row]")].some((row) => row.textContent?.includes("local E1 ROW-497"))); + await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); + await page.evaluate((height) => window.chatQa.resize(height), width === 390 ? 844 : 900); + await findButton.click(); + await search.fill("FULL OUTPUT END"); + await match.filter({ hasText: "local E1 ROW-497" }).waitFor(); + await page.keyboard.press("Escape"); + assert.equal(await work.locator(".work-block-head").getAttribute("aria-expanded"), "true"); + assert.equal(await work.locator(".work-row-head").getAttribute("aria-expanded"), "true"); + assert.equal(await work.locator(".chat-tool-io.is-whole").textContent(), WHOLE_OUTPUT); + assert.equal(wholeReads, 1, "restored output is not fetched again"); + await shot(page, `restored-work-${width}-${theme}`); + console.log(`PASS ${width} ${theme}: outside-row selection, focused row, disclosures and whole-output search survive eviction`); + + await page.reload(); + await page.getByText("REVISION-102", { exact: true }).waitFor(); + await settleBottom(page); + await findButton.click(); + await search.fill("MISSING-SENTINEL"); + holdOlder = true; + const beforeCancel = olderReads; + const searchStarted = new Promise((resolve, reject) => { + const received = () => { clearTimeout(timer); resolve(); }; + const timer = setTimeout(() => { olderEvents.removeEventListener("requested", received); reject(new Error("Held search request missing")); }, 10_000); + olderEvents.addEventListener("requested", received, { once: true }); + }); + await page.getByRole("button", { name: "Search earlier messages" }).click(); + await searchStarted; + await page.getByRole("button", { name: "Cancel search" }).click(); + const cancelledPage = page.waitForResponse((response) => new URL(response.url()).searchParams.has("before")); + holdOlder = false; + olderReleases.splice(0).forEach((release) => release()); + await (await cancelledPage).finished(); + await page.waitForFunction(() => document.querySelector(".chat-find-options .btn")?.disabled === false); + assert.equal(olderReads, beforeCancel + 1, "cancel stops further history reads after the in-flight page"); + failOlder = true; + await page.getByRole("button", { name: "Search earlier messages" }).click(); + await page.locator(".chat-find [role=alert]").waitFor(); + failOlder = false; + await search.fill("local E1 ROW--40"); + await page.getByRole("button", { name: "Search earlier messages" }).click(); + await match.filter({ hasText: "local E1 ROW--40" }).waitFor(); + await page.keyboard.press("Escape"); + await page.evaluate(() => window.chatQa.machine("remote")); + await page.getByText("remote E1 ROW-999", { exact: true }).waitFor(); + assert.equal(await page.locator(".chat-find").count(), 0); + assert.ok((await metrics(page)).keys.every((key) => key?.includes('"remote"')), "same-pane remote rows have a separate identity scope"); + console.log(`PASS ${width} ${theme}: earlier search cancels and retries; another PC does not inherit search or rows`); + const remoteRow = page.locator("[data-chat-row]").filter({ hasText: "remote E1 ROW-999" }); + await remoteRow.evaluate((row) => { + const range = document.createRange(); range.selectNodeContents(row); + getSelection()?.removeAllRanges(); getSelection()?.addRange(range); + }); + await page.waitForFunction(() => Number(document.querySelector(".chat-view")?.getAttribute("data-chat-pinned")) > 0); + epoch = 2; + await update(page); + await page.getByText("remote E2 ROW-999", { exact: true }).waitFor(); + await page.waitForFunction(() => document.querySelector(".chat-view")?.getAttribute("data-chat-pinned") === "0"); + assert.equal(await page.getByText("remote E1 ROW-999", { exact: true }).count(), 0); + console.log(`PASS ${width} ${theme}: history replacement releases selected old rows`); + for (const origin of [false, true]) { + newestStart = 0; beginsAtOrigin = origin; epoch = 1; + await page.reload(); + await settleBottom(page); + await findButton.click(); + await search.fill("local E1 ROW-497"); + await match.filter({ hasText: "local E1 ROW-497" }).waitFor(); + await page.keyboard.press("Escape"); + const retained = page.locator("[data-chat-row]").filter({ hasText: "local E1 ROW-497" }); + await retained.locator(".work-block-head").click(); + await retained.locator(".work-row-head").click(); + const identity = await retained.getAttribute("data-chat-row"); + const selectedText = await retained.evaluate((row) => { + const paragraph = [...row.querySelectorAll("p")].find((p) => p.textContent === "local E1 ROW-497"); + if (!paragraph) throw new Error("Sliding-page selection target missing"); + const range = document.createRange(); range.selectNodeContents(paragraph); + getSelection()?.removeAllRanges(); getSelection()?.addRange(range); + return getSelection()?.toString(); + }); + await page.waitForFunction(() => Number(document.querySelector(".chat-view")?.getAttribute("data-chat-pinned")) > 0); + const y = await retained.evaluate((row) => row.getBoundingClientRect().top); + const beforeSlide = slideReads; + newestStart = 2; + await update(page); + await page.getByRole("button", { name: "New messages" }).waitFor(); + assert.equal(slideReads, beforeSlide + 1, "a moving newest cursor fills the dropped prefix even before older history was requested"); + assert.equal(await retained.getAttribute("data-chat-row"), identity); + assert.equal(await page.evaluate(() => getSelection()?.toString()), selectedText); + assert.equal(await retained.locator(".work-block-head").getAttribute("aria-expanded"), "true"); + assert.equal(await retained.locator(".work-row-head").getAttribute("aria-expanded"), "true"); + assert.ok(Math.abs(await retained.evaluate((row) => row.getBoundingClientRect().top) - y) <= 1, "sliding latest page preserves the logical reading anchor"); + await page.evaluate(() => getSelection()?.removeAllRanges()); + console.log(`PASS ${width} ${theme}: unheld newest cursor slides from ${origin ? "origin" : "a page"} without moving row identity, selection, disclosure or reading anchor`); + } + assert.deepEqual(errors, []); + await context.close(); + } + console.log("chat virtual: initial, streaming, manual follow and source search OK"); +} catch (error) { + if (evidence && activePage && !activePage.isClosed()) { + try { await activePage.screenshot({ path: join(evidence, "chat-virtual-failed.png") }); } + catch (captureError) { console.error("Chat failure capture:", String(captureError)); } + } + throw error; +} finally { + olderReleases.forEach((release) => release()); + await browser?.close(); + server?.stop(true); + rmSync(root, { recursive: true, force: true }); +} diff --git a/scripts/chat-virtual-fixture.tsx b/scripts/chat-virtual-fixture.tsx new file mode 100644 index 000000000..25cdfab53 --- /dev/null +++ b/scripts/chat-virtual-fixture.tsx @@ -0,0 +1,44 @@ +import "../src/styles.css"; +import { Profiler, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { ChatView } from "../src/components/ChatView.tsx"; +import { MachineContext } from "../src/lib/machineContext.tsx"; +import { SettingsProvider } from "../src/lib/settings.ts"; +import { OpenFileContext } from "../src/lib/filePaths.ts"; + +declare global { + interface Window { + chatQa: { + refresh: () => void; + page: (direction: -1 | 1) => void; + resize: (height: number) => void; + machine: (machine: string) => void; + }; + } +} + +function Fixture() { + const [refresh, setRefresh] = useState(0); + const [height, setHeight] = useState(window.innerHeight); + const [machine, setMachine] = useState("local"); + const pageScroll = useRef<((direction: -1 | 1) => void) | null>(null); + window.chatQa = { + refresh: () => setRefresh((value) => value + 1), + page: (direction) => pageScroll.current?.(direction), + resize: setHeight, + machine: setMachine, + }; + return
+ + window.dispatchEvent(new Event("chat-qa-commit"))}> + {}}> + + + + +
; +} + +const root = document.getElementById("root"); +if (!root) throw new Error("Chat fixture root missing"); +createRoot(root).render(); diff --git a/scripts/check.test.ts b/scripts/check.test.ts index 1719b8831..4c0b6e3ce 100644 --- a/scripts/check.test.ts +++ b/scripts/check.test.ts @@ -1,15 +1,18 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { acquire, describeHolders, isolate, lock, maxRuns, needsLock, plan, sanitizeCommand } from "./check.ts"; +import { acquire, acquireWaiting, checkoutIdentity, describeHolders, isolate, lock, maxRuns, needsLock, plan, sanitizeCommand, waitSeconds } from "./check.ts"; const made: string[] = []; afterEach(() => { for (const path of made.splice(0)) rmSync(path, { recursive: true, force: true }); }); -const scratch = (): string => { const dir = mkdtempSync(join(tmpdir(), "hwc-test-")); made.push(dir); return dir; }; +// /tmp where there is one, as `isolate` itself prefers: macOS keeps its temp dir under a path so long +// that a socket inside a directory made there is past the limit `isolate` enforces +const scratchBase = existsSync("/tmp") ? realpathSync("/tmp") : tmpdir(); +const scratch = (): string => { const dir = mkdtempSync(join(scratchBase, "hwc-")); made.push(dir); return dir; }; const git = (cwd: string, ...args: string[]): void => { const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" }); if (result.status !== 0) throw new Error(result.stderr || `git ${args.join(" ")} failed`); @@ -205,6 +208,68 @@ describe("slots", () => { } }); + it("tells runners that share a loopback apart although they check out to one path", async () => { + const base = await freeRange(3); + const path = "/w/herdr-web-ui/herdr-web-ui"; + expect(checkoutIdentity(path, {})).toBe(path); + const first = checkoutIdentity(path, { RUNNER_NAME: "herdr-ci-pc-1" }); + const second = checkoutIdentity(path, { RUNNER_NAME: "herdr-ci-pc-2" }); + expect(first).not.toBe(second); + const other = await lock(base, 111, first); + try { + const mine = await acquire({ slots: 2, scan: 3, base, pid: 222, checkout: second }); + expect(mine).toMatchObject({ port: base + 1 }); + release(mine); + const again = await acquire({ slots: 2, scan: 3, base, pid: 222, checkout: first }); + expect(again).toEqual({ refused: "same-checkout", holders: [{ port: base, heldBy: 111, checkout: first }] }); + } finally { + release(other); + } + }); + + it("waits for a busy slot and takes it once its holder ends", async () => { + const base = await freeRange(3); + const holder = await lock(base, 111, "/a"); + let told: unknown = null; + const waiting = acquireWaiting({ slots: 1, scan: 3, base, pid: 222, checkout: "/b" }, { + waitMs: 10_000, + pollMs: 20, + onWait: (holders) => { told = holders; release(holder); }, + }); + const result = await waiting; + try { + expect(told).toEqual([{ port: base, heldBy: 111, checkout: "/a" }]); + expect(result).toMatchObject({ port: base }); + } finally { + release(result); + } + }); + + it("stops waiting at its deadline, and never waits on a run in its own checkout", async () => { + const base = await freeRange(3); + const busy = await lock(base, 111, "/a"); + try { + let waits = 0; + const late = await acquireWaiting({ slots: 1, scan: 3, base, pid: 222, checkout: "/b" }, { waitMs: 60, pollMs: 20, onWait: () => { waits += 1; } }); + expect(late).toEqual({ refused: "busy", holders: [{ port: base, heldBy: 111, checkout: "/a" }] }); + expect(waits).toBe(1); + const now = await acquireWaiting({ slots: 2, scan: 3, base, pid: 222, checkout: "/a" }, { waitMs: 10_000, pollMs: 20, onWait: () => { waits += 1; } }); + expect(now).toEqual({ refused: "same-checkout", holders: [{ port: base, heldBy: 111, checkout: "/a" }] }); + expect(waits).toBe(1); + } finally { + release(busy); + } + }); + + it("reads CHECK_WAIT_SECONDS as 0 to 7200, else no wait", () => { + expect(waitSeconds({})).toBe(0); + expect(waitSeconds({ CHECK_WAIT_SECONDS: "" })).toBe(0); + expect(waitSeconds({ CHECK_WAIT_SECONDS: "0" })).toBe(0); + expect(waitSeconds({ CHECK_WAIT_SECONDS: "1800" })).toBe(1800); + expect(waitSeconds({ CHECK_WAIT_SECONDS: "7200" })).toBe(7200); + for (const value of ["7201", "-1", "1.5", "ten", " 30"]) expect(() => waitSeconds({ CHECK_WAIT_SECONDS: value })).toThrow("CHECK_WAIT_SECONDS"); + }); + it("reads CHECK_MAX_RUNS as 1 to 8, else one slot per eight cores, from 1 to 2", () => { expect(maxRuns({}, 32)).toBe(2); expect(maxRuns({}, 64)).toBe(2); diff --git a/scripts/check.ts b/scripts/check.ts index 638a8f1f2..548c24fb0 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -187,6 +187,33 @@ export function maxRuns(env: Record, cores = availab return count; } +/** + * What a run calls its checkout to the runs that share its slots. A self-hosted CI runner shares its + * loopback with the other runner containers of its PC (scripts/self-hosted-runner/run.sh), and every + * one of them checks out to the same path, so there the runner's name (RUNNER_NAME, set by the + * Actions runner) is part of it: two runners are two checkouts. + */ +export function checkoutIdentity(checkout: string, env: Record = process.env): string { + const runner = env["RUNNER_NAME"]; + return runner ? `${runner}:${checkout}` : checkout; +} + +/** The most CHECK_WAIT_SECONDS may ask for: two hours, past any CI job's timeout. */ +export const MAX_WAIT_SECONDS = 7200; + +/** + * How long a run waits for a slot when the run limit is reached: CHECK_WAIT_SECONDS (0 to 7200), + * else 0, which refuses at once. CI sets it: a job that finds the PC busy waits its turn instead + * of failing. A run in the same checkout, or a held resource, is still refused at once. + */ +export function waitSeconds(env: Record): number { + const asked = env["CHECK_WAIT_SECONDS"]; + if (asked === undefined || asked === "") return 0; + const seconds = /^\d+$/.test(asked) ? Number(asked) : Number.NaN; + if (!(seconds >= 0 && seconds <= MAX_WAIT_SECONDS)) throw new Error(`CHECK_WAIT_SECONDS must be a whole number from 0 to ${MAX_WAIT_SECONDS}, not "${asked}"`); + return seconds; +} + /** What a slot's holder says: its pid (NaN when what listens there is no run) and its checkout, when it names one. */ export type Holder = { heldBy: number; checkout?: string }; @@ -316,6 +343,25 @@ export async function acquire( } } +/** + * acquire, asked again every `pollMs` while the only refusal is a busy PC, until `waitMs` has + * passed. `onWait` hears the first busy refusal, so a waiting run says once whom it waits for. + */ +export async function acquireWaiting( + options: Parameters[0], + { waitMs, pollMs = 2_000, onWait }: { waitMs: number; pollMs?: number; onWait?: (holders: SlotHolder[]) => void }, +): ReturnType { + const deadline = Date.now() + waitMs; + let held = await acquire(options); + let told = false; + while ("refused" in held && held.refused === "busy" && Date.now() < deadline) { + if (!told) { onWait?.(held.holders); told = true; } + await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now())))); + held = await acquire(options); + } + return held; +} + /** One line per holder for a refusal. */ export function describeHolders(holders: readonly SlotHolder[]): string { return holders.map((holder) => Number.isNaN(holder.heldBy) @@ -348,8 +394,21 @@ async function main(): Promise { if (needsHerdr || resources.length > 0 || reports.length > 0) { try { let slots: number; - try { slots = needsHerdr ? maxRuns(process.env) : 0; } catch (error) { console.error(`check: ${(error as Error).message}`); process.exit(2); } - const held = await acquire({ slots, checkout: realpathSync(cwd), resources, reports }); + let wait: number; + try { + slots = needsHerdr ? maxRuns(process.env) : 0; + wait = waitSeconds(process.env); + } catch (error) { console.error(`check: ${(error as Error).message}`); process.exit(2); } + const waitStarted = Date.now(); + let waited = false; + const held = await acquireWaiting({ slots, checkout: checkoutIdentity(realpathSync(cwd)), resources, reports }, { + waitMs: wait * 1000, + onWait: (holders) => { + waited = true; + console.error(`check: the ${slots}-run limit is reached (${describeHolders(holders)}); waiting up to ${wait}s for a slot`); + }, + }); + if (waited && !("refused" in held)) console.error(`check: took a slot after ${Math.round((Date.now() - waitStarted) / 1000)}s`); if ("refused" in held) { const message = held.refused === "same-checkout" ? `another herdr-backed \`bun run check\` is running in this checkout, and the two would share dist/ and the generated types (${describeHolders(held.holders)}); wait for it to end or use another worktree` diff --git a/scripts/chrome-path.ts b/scripts/chrome-path.ts new file mode 100644 index 000000000..c7e0ade27 --- /dev/null +++ b/scripts/chrome-path.ts @@ -0,0 +1,13 @@ +import { existsSync } from "node:fs"; +import { chromium } from "playwright-core"; + +const LINUX_CHROME = "/opt/google/chrome/chrome"; + +/** + * The browser a script launches: `CHROME_PATH` when it is set (the browser lane sets it), Google + * Chrome's Linux package where it is installed, and otherwise the lockfile's Playwright Chromium, + * which is what a Mac or a Windows PC has (`bun node_modules/playwright-core/cli.js install chromium`). + */ +export function chromePath(): string { + return process.env["CHROME_PATH"] || (existsSync(LINUX_CHROME) ? LINUX_CHROME : chromium.executablePath()); +} diff --git a/scripts/ci-browser-parallel.ts b/scripts/ci-browser-parallel.ts index 728306c3e..6876246e8 100644 --- a/scripts/ci-browser-parallel.ts +++ b/scripts/ci-browser-parallel.ts @@ -7,10 +7,15 @@ * BROWSER_WORKERS: worker count (default: min(cores/2, 6)) * BROWSER_SCRIPTS: comma-separated list of scripts to run (default: all from ci-browser.sh) * BROWSER_EXCLUDE: comma-separated list of scripts to leave out of that list + * BROWSER_RETRY: 0 runs a failed script once only (default: a failed script runs a second time) + * + * A script that fails and then passes is reported as flaky, not hidden: its first log is kept as + * `; - writeFileSync(index, html.replace(/\n \n \n \n `, { headers: { "content-type": "text/html" } }); } }); - browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); + browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); const cases = [ { name: "portrait", width: 393, height: 852, touch: true, safe: "none", top: 0, side: 0, bottom: 0 }, { name: "portrait-notch", width: 393, height: 852, touch: true, safe: "portrait", top: 59, side: 0, bottom: 34 }, diff --git a/scripts/file-viewer-regression.ts b/scripts/file-viewer-regression.ts index e985df8c1..1da8fbc07 100644 --- a/scripts/file-viewer-regression.ts +++ b/scripts/file-viewer-regression.ts @@ -11,6 +11,8 @@ import { herdrRpc, workspaceClose, workspaceCreate } from "../server/herdr/clien import { openSettingsPage } from "./settings-page.ts"; import { sheetsSettled } from "./sheets-settled.ts"; import type { Machine } from "../shared/protocol.ts"; +import { FAVICON_CANDIDATES } from "../src/lib/projectFavicon.ts"; +import { chromePath } from "./chrome-path.ts"; const root = mkdtempSync(join(tmpdir(), "herdr-web-ui-file-back-")); const codexHome = join(root, "codex-home"); @@ -21,8 +23,9 @@ const fidelitySources = ["const last = 7;\n\n", "first\n", "first\n\nlast", "\n" writeFileSync(transcript, [ { type: "session_meta", payload: { id: thread, cwd: root } }, { type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "Show me the demo video." }] } }, - { type: "response_item", payload: { type: "message", role: "assistant", phase: "final_answer", content: [{ type: "output_text", text: `Open [demo video](./preview.webm) or [notes](./notes.txt) or [guide](./docs/guide.md) or [big](./big.txt) or [big code](./big.ts) or [deep quotes](./deep.md) or [empty](./empty.txt) or [slow preview](./slow.md) or [long notes](./long.md) or [many lines](./lines.txt) or [long list](./list.md) or [file URI notes](${new URL(`file://${join(root, "notes.txt")}`).href}) or [folder](${new URL(`file://${root}`).href}).\n\n${new URL(`file://${join(root, "notes.txt")}`).href}\n\n\`\`\`ts\nconst answer = 42;\n\nexport { answer };\n\`\`\`` }] } }, + { type: "response_item", payload: { type: "message", role: "assistant", phase: "final_answer", content: [{ type: "output_text", text: `Open [demo video](./preview.webm) or [notes](./notes.txt) or [guide](./docs/guide.md) or [big](./big.txt) or [big code](./big.ts) or [mid code](./mid.ts) or [deep quotes](./deep.md) or [empty](./empty.txt) or [slow preview](./slow.md) or [long notes](./long.md) or [many lines](./lines.txt) or [long list](./list.md) or [file URI notes](${new URL(`file://${join(root, "notes.txt")}`).href}) or [folder](${new URL(`file://${root}`).href}).\n\n${new URL(`file://${join(root, "notes.txt")}`).href}\n\n\`\`\`ts\nconst answer = 42;\n\nexport { answer };\n\`\`\`` }] } }, // a block the highlighter is quadratic on (a line of dashes in YAML): seconds on the page + { type: "response_item", payload: { type: "message", role: "assistant", phase: "final_answer", content: [{ type: "output_text", text: "References: [source column](./target.ts:60:8), [source range](./target.ts#L60-L65), [Markdown line](./docs/guide.md#L6), [past end](./notes.txt:999), [past preview](./big.txt:999999), [capped line](./lines.txt:5)." }] } }, { type: "response_item", payload: { type: "message", role: "assistant", phase: "final_answer", content: [{ type: "output_text", text: `Dashes:\n\n\`\`\`yaml\n${"-".repeat(90_000)}\n\`\`\`` }] } }, // more lines than are drawn one element each (LINE_ELEMENT_LIMIT): 30 000 elements held the page { type: "response_item", payload: { type: "message", role: "assistant", phase: "final_answer", content: [{ type: "output_text", text: `Lines:\n\n\`\`\`\n${"x\n".repeat(30_000)}\`\`\`` }] } }, @@ -42,14 +45,21 @@ writeFileSync(standIn, "#!/bin/sh\nsleep 600\n"); chmodSync(standIn, 0o755); copyFileSync(join(import.meta.dir, "fixtures", "file-preview.webm"), join(root, "preview.webm")); writeFileSync(join(root, "notes.txt"), "File preview history regression\n"); +const targetSource = Array.from({ length: 80 }, (_, index) => `export const line${index + 1} = ${index + 1};`).join("\n") + "\n\n"; +writeFileSync(join(root, "target.ts"), targetSource); // in a subfolder, so a link is resolved from the file's folder (../notes.txt), not the pane's mkdirSync(join(root, "docs")); writeFileSync(join(root, "docs", "guide.md"), "# Guide\n\nSee [notes](../notes.txt).\n\n```ts\nconst x = 1;\n```\n"); -// the viewer loads the first 256 KB of a text file (TEXT_PREVIEW_BYTES in src/lib/filePreviewText.ts) +// the viewer loads the first 256 KB of a Markdown file (TEXT_PREVIEW_BYTES in src/lib/filePreviewText.ts), +// and up to 8 MB or 200 000 lines of any other text (SOURCE_PREVIEW_BYTES, SOURCE_PREVIEW_LINES) const TEXT_LOAD_LIMIT = 256 * 1024; -writeFileSync(join(root, "big.txt"), "a line of plain text, 0123456789\n".repeat(Math.ceil(TEXT_LOAD_LIMIT / 30)).slice(0, TEXT_LOAD_LIMIT + 10)); -// code past the load limit: its first 256 KB, still highlighted +const SOURCE_LOAD_LIMIT = 8 * 1024 * 1024; +// plain text past the source budget, in lines long enough that the bytes cut it before the line cap +writeFileSync(join(root, "big.txt"), `a line of plain text, ${"0123456789".repeat(8)}\n`.repeat(Math.ceil((SOURCE_LOAD_LIMIT + 1024 * 1024) / 103))); +// code past the colors bound of a virtualized source (VIRTUAL_HIGHLIGHT_CHARS in src/lib/sourcePreview.ts): whole, plain writeFileSync(join(root, "big.ts"), "export const value = 1;\n".repeat(Math.ceil((TEXT_LOAD_LIMIT + 1024) / 24))); +// code past an element per line (VIRTUAL_SOURCE_LINES) but within the colors bound: colored in the virtualizer's worker +writeFileSync(join(root, "mid.ts"), Array.from({ length: 3_000 }, (_, index) => `export const value${index + 1} = ${index + 1};`).join("\n") + "\n"); // 20 KB of nested quotes: parsed one level per `>`, they overflowed the stack and blanked the app writeFileSync(join(root, "deep.md"), `${">".repeat(20_000)} deepest\n`); // a YAML block of dashes in a Markdown file: seconds for the highlighter in Chromium as in Bun (8 s at @@ -58,7 +68,7 @@ writeFileSync(join(root, "deep.md"), `${">".repeat(20_000)} deepest\n`); writeFileSync(join(root, "slow.md"), `Notes on a slow file.\n\n\`\`\`yaml\n${"-".repeat(90_000)}\n\`\`\`\n`); // ordinary Markdown past the load limit: its start is previewed writeFileSync(join(root, "long.md"), "# Notes\n\nA paragraph of ordinary notes, with *emphasis* and a [link](./notes.txt).\n\n".repeat(4_000)); -// more lines than are drawn one element each (LINE_ELEMENT_LIMIT): 256 KB of line breaks held the page 4 s +// more lines than the source budget keeps (SOURCE_PREVIEW_LINES): 256 KB of line breaks, cut at the line cap writeFileSync(join(root, "lines.txt"), "\n".repeat(TEXT_LOAD_LIMIT)); // more list items than a Preview draws (PREVIEW_ELEMENT_LIMIT): 65 536 of them held the page 1.6 s writeFileSync(join(root, "list.md"), "- a\n".repeat(TEXT_LOAD_LIMIT / 4)); @@ -97,7 +107,7 @@ try { await herdrRpc("pane.report_agent", { pane_id: pane, source: "manual", agent: "codex", state: "idle", agent_session_path: transcript }); server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: join(root, "state"), codexHome }); const origin = `http://127.0.0.1:${server.port}`; - browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); + browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); const page = await context.newPage(); // seeded once: a later step changes a setting and reloads. The chat's own font size and @@ -501,21 +511,27 @@ try { await page.getByRole("button", { name: "Close file", exact: true }).click(); await page.locator(".file-viewer").waitFor({ state: "hidden" }); - // a text file past the load limit: shown in part, so it cannot be copied whole + // a text file past the source budget: its first 8 MB as a window of rows, shown in part, so it cannot be copied whole + const virtualRows = () => page.evaluate(() => document.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0); + const coloredRows = () => page.evaluate(() => document.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line] span[style*='--herdr-code-token']").length ?? 0); await page.getByRole("button", { name: "big", exact: true }).click(); const big = page.getByRole("dialog", { name: "big.txt", exact: true }); - // the note is the header's size, where it is seen before the text: "256 KB of …" + // the note is the header's size, where it is seen before the text: "8 MB of 9 MB" const cut = big.locator(".file-viewer-meta .file-viewer-notice"); await cut.waitFor(); - assert.match(await cut.innerText(), /^256 KB of \d/); - assert.match(await cut.getAttribute("title") ?? "", /^Showing the first 256 KB/); + assert.match(await cut.innerText(), /^8 MB of 9 MB/); + assert.equal(await cut.getAttribute("title"), "Showing the first 8 MB of 9 MB."); assert.equal(await big.getByRole("button", { name: "Copy file", exact: true }).count(), 0); assert.equal(await big.getByText("Too long to highlight").count(), 0, "plain text is never too long to highlight"); - console.log("PASS A text file past the load limit says so in its header and has no Copy"); + await page.waitForFunction(() => (document.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0) > 0); + assert.ok((await virtualRows()) < 500, "8 MB of text is a bounded window of rows"); + assert.equal(await big.locator(".file-viewer-text").count(), 0, "not an element per line"); + assert.deepEqual(await frozen(), [], "no task held the page for a second"); + console.log("PASS A text file past the source budget shows its first 8 MB as a window of rows, says so in its header and has no Copy"); await page.getByRole("button", { name: "Close file", exact: true }).click(); await page.locator(".file-viewer").waitFor({ state: "hidden" }); - // code past the load limit: its first 256 KB, highlighted (in the worker), and the header says it is cut + // code past the colors bound: whole, as a window of plain rows, and the header says why it is plain await page.evaluate(() => { const settings = JSON.parse(localStorage.getItem("herdr-web-ui:settings") ?? "{}") as Record; localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ ...settings, wrapCode: true })); @@ -524,15 +540,84 @@ try { await page.locator(".conn-live").waitFor(); await page.getByRole("button", { name: "big code", exact: true }).click(); const bigCode = page.getByRole("dialog", { name: "big.ts", exact: true }); - await bigCode.locator(".file-viewer-text .hl-keyword", { hasText: "export" }).first().waitFor(); - assert.match(await bigCode.locator(".file-viewer-meta .file-viewer-notice").getAttribute("title") ?? "", /^Showing the first 256 KB of /); - assert.equal(await bigCode.getByRole("button", { name: "Copy file", exact: true }).count(), 0); - console.log("PASS Code past the load limit shows its first 256 KB, highlighted"); - await bigCode.locator(".file-viewer-text[data-wrap]").waitFor(); + await page.waitForFunction(() => (document.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0) > 0); + assert.equal(await bigCode.locator(".file-viewer-meta .file-viewer-notice").getAttribute("title"), "Too long to highlight"); + assert.equal(await coloredRows(), 0, "the rows are plain"); + await bigCode.getByRole("button", { name: "Copy file", exact: true }).waitFor(); + console.log("PASS Code past the colors bound opens whole as plain rows, and the header says why"); + assert.equal(await page.evaluate(() => document.querySelector("diffs-container")!.shadowRoot!.querySelector("pre")!.getAttribute("data-overflow")), "wrap"); console.log("PASS Settings → Wrap long lines wraps the code"); await page.getByRole("button", { name: "Close file", exact: true }).click(); await page.locator(".file-viewer").waitFor({ state: "hidden" }); + // code past an element per line but within the colors bound: colored in the virtualizer's worker, served by this server under its CSP + await page.getByRole("button", { name: "mid code", exact: true }).click(); + const midCode = page.getByRole("dialog", { name: "mid.ts", exact: true }); + await page.waitForFunction(() => (document.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line] span[style*='--herdr-code-token']").length ?? 0) > 0, undefined, { timeout: 20_000 }); + assert.equal(await midCode.locator(".file-viewer-notice").count(), 0, "a colored source has no notice"); + await midCode.getByRole("button", { name: "Copy file", exact: true }).waitFor(); + assert.deepEqual(errors, []); + console.log("PASS Code of 3 000 lines is colored in the virtualizer's worker under the server's CSP"); + await page.getByRole("button", { name: "Close file", exact: true }).click(); + await page.locator(".file-viewer").waitFor({ state: "hidden" }); + + for (const label of ["source column", "source range"]) { + await page.getByRole("button", { name: label, exact: true }).click(); + const target = page.getByRole("dialog", { name: "target.ts", exact: true }); + const marked = target.locator(".file-viewer-text .hl-line.is-marked"); + await marked.waitFor(); + await sheetsSettled(page); + assert.equal(await marked.count(), 1); + assert.equal(await marked.getAttribute("data-line"), "60"); + assert.equal(await marked.textContent(), "export const line60 = 60;"); + assert.equal(await target.locator(".file-viewer-text code").textContent(), targetSource); + const visible = await marked.evaluate((line) => { + const body = line.closest(".file-viewer-body"); + if (!body) return false; + const target = line.getBoundingClientRect(), viewport = body.getBoundingClientRect(); + return target.top >= viewport.top && target.bottom <= viewport.bottom && body.scrollTop > 0; + }); + assert.ok(visible, "requested first line is scrolled into the viewer"); + await target.getByRole("button", { name: "Close file", exact: true }).click(); + await target.waitFor({ state: "hidden" }); + } + await page.getByRole("button", { name: "Markdown line", exact: true }).click(); + const anchored = page.getByRole("dialog", { name: "guide.md", exact: true }); + await anchored.locator(".hl-line.is-marked[data-line='6']").waitFor(); + assert.equal(await anchored.getByRole("button", { name: "Show source", exact: true }).getAttribute("aria-pressed"), "true"); + const popupOpened = page.waitForEvent("popup"); + await anchored.getByRole("link", { name: "Open in a new tab", exact: true }).click(); + const anchoredTab = await popupOpened; + await anchoredTab.locator(".file-reader .hl-line.is-marked[data-line='6']").waitFor(); + assert.ok(new URL(anchoredTab.url()).searchParams.get("read")?.endsWith("guide.md#L6")); + await anchoredTab.close(); + await anchored.getByRole("button", { name: "Show source", exact: true }).click(); + await anchored.locator(".file-viewer-markdown").getByRole("heading", { name: "Guide", exact: true }).waitFor(); + assert.equal(await anchored.locator(".hl-line.is-marked").count(), 0); + await anchored.getByRole("button", { name: "Close file", exact: true }).click(); + await anchored.waitFor({ state: "hidden" }); + for (const [label, name, line] of [["past end", "notes.txt", "999"], ["past preview", "big.txt", "999999"]] as const) { + await page.getByRole("button", { name: label, exact: true }).click(); + const target = page.getByRole("dialog", { name, exact: true }); + await target.locator(".file-viewer-text, diffs-container").first().waitFor(); + const notice = target.locator(".file-viewer-notice"); + await notice.waitFor(); + assert.ok((await notice.getAttribute("title"))?.includes(line), "notice names the unavailable requested line"); + assert.equal(await target.locator(".hl-line.is-marked").count(), 0, "no misleading jump"); + assert.equal(await page.evaluate(() => document.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line][data-selected-line]").length ?? 0), 0, "no misleading mark"); + await target.getByRole("button", { name: "Close file", exact: true }).click(); + await target.waitFor({ state: "hidden" }); + } + // a file cut at the line cap keeps every line it shows numbered, so a line within it is marked + await page.getByRole("button", { name: "capped line", exact: true }).click(); + const capped = page.getByRole("dialog", { name: "lines.txt", exact: true }); + await page.waitForFunction(() => document.querySelector("diffs-container")?.shadowRoot?.querySelector('[data-line="5"][data-selected-line]') != null); + assert.equal(await capped.locator(".file-viewer-notice").getAttribute("title"), "Showing the first 200,000 lines of this file. Download it to read the rest."); + assert.equal(await capped.getByRole("button", { name: "Copy file", exact: true }).count(), 0, "a cut file has no Copy"); + await capped.getByRole("button", { name: "Close file", exact: true }).click(); + await capped.waitFor({ state: "hidden" }); + console.log("PASS Line references mark and scroll the first source line, survive Markdown reader tabs, and explain unavailable lines"); + // a quote nested 20 000 deep renders as a quote nested to the parser's limit, the rest its text await page.getByRole("button", { name: "deep quotes", exact: true }).click(); const deep = page.getByRole("dialog", { name: "deep.md", exact: true }); @@ -639,6 +724,14 @@ try { await popup.goBack(); await rendered.getByRole("heading", { name: "Markdown preview", exact: true }).waitFor(); await popup.close(); + await page.goto(`${origin}/?pane=${encodeURIComponent(pane)}&file=${encodeURIComponent(join(root, "target.ts") + ":60:8")}`); + const marked = page.locator(".file-viewer-text .hl-line.is-marked"); + await marked.waitFor(); + await sheetsSettled(page); + await page.evaluate(() => document.fonts.ready.then(() => undefined)); + assert.equal(await marked.getAttribute("data-line"), "60"); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + if (process.env.UI_EVIDENCE_DIR) await page.screenshot({ path: join(process.env.UI_EVIDENCE_DIR, `source-line-${width}-${theme}.png`) }); await context.close(); console.log(`PASS Markdown inline/new tab/reload/raw/download: ${width}px ${theme}`); } @@ -663,7 +756,10 @@ try { if (request.pathname === "/api/machines") { await route.fulfill({ json: { machines: [...roster.machines, remote] } }); return; } if (request.pathname === "/api/machines/remote-fixture/session") { await route.fulfill({ json: { snapshot: remote.snapshot } }); return; } if (request.pathname.includes("/fs/")) { - fileRequests.push(request.pathname); + // the sidebar probes each workspace folder for its project icon on its own PC; only the + // document's reads are the link's + const asked = request.searchParams.get("path") ?? ""; + if (!FAVICON_CANDIDATES.some((candidate) => asked.endsWith(`/${candidate}`))) fileRequests.push(request.pathname); if (request.pathname.startsWith("/api/machines/remote-fixture/")) { request.pathname = request.pathname.replace("/api/machines/remote-fixture/", "/api/"); await route.fulfill({ response: await route.fetch({ url: request.href }) }); @@ -750,23 +846,25 @@ try { await page.getByRole("button", { name: "Close file", exact: true }).click(); await page.locator(".file-viewer").waitFor({ state: "hidden" }); - // a file of more lines than are numbered shows as one text, and a Markdown file of more than a - // Preview draws opens as its source; neither holds the page, and the header says why + // a file of more lines than the source budget keeps shows its first 200 000 as a window of rows, + // and a Markdown file of more than a Preview draws opens as its source; neither holds the page, + // and the header says why await page.getByRole("button", { name: "many lines", exact: true }).click(); const manyLines = page.getByRole("dialog", { name: "lines.txt", exact: true }); - await page.waitForFunction(() => document.querySelector(".file-viewer-notice")?.getAttribute("title") === "Too many lines to number"); - await manyLines.locator(".file-viewer-text").waitFor(); - assert.equal(await manyLines.locator(".file-viewer-text .hl-line").count(), 0, "no element per line"); - assert.equal(await manyLines.locator(".file-viewer-text[data-line-numbers]").count(), 0, "and no gutter"); + await page.waitForFunction(() => document.querySelector(".file-viewer-notice")?.getAttribute("title") === "Showing the first 200,000 lines of this file. Download it to read the rest."); + await page.waitForFunction(() => (document.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0) > 0); + assert.equal(await manyLines.locator(".file-viewer-text").count(), 0, "no element per line"); + assert.ok((await virtualRows()) < 500, "a bounded window of rows"); await page.getByRole("button", { name: "Close file", exact: true }).click(); await page.locator(".file-viewer").waitFor({ state: "hidden" }); await page.getByRole("button", { name: "long list", exact: true }).click(); const longList = page.getByRole("dialog", { name: "list.md", exact: true }); await page.waitForFunction(() => (document.querySelector(".file-viewer-notice")?.getAttribute("title") ?? "").includes("Too long to preview"), undefined, { timeout: 15_000 }); - await longList.locator(".file-viewer-text").waitFor(); + // its 65 536-line source is past an element per line: a window of rows + await page.waitForFunction(() => (document.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0) > 0); assert.equal(await longList.locator(".file-viewer-markdown").count(), 0, "the Preview is not drawn"); assert.deepEqual(await frozen(), [], "no task held the page for a second"); - console.log("PASS A file of too many lines shows as one text, a Markdown file of too many elements as its source, and the page never freezes"); + console.log("PASS A file of too many lines shows its first 200 000 as rows, a Markdown file of too many elements as its source, and the page never freezes"); await page.getByRole("button", { name: "Close file", exact: true }).click(); await page.locator(".file-viewer").waitFor({ state: "hidden" }); @@ -833,28 +931,6 @@ try { assert.deepEqual(errors, []); console.log("PASS A file path in a plain code block opens the file, and the block's text is unchanged"); - // Settings → Highlight code, off: code is plain text, in the chat and the viewer - await page.evaluate(() => { - const settings = JSON.parse(localStorage.getItem("herdr-web-ui:settings") ?? "{}") as Record; - localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ ...settings, highlightCode: false })); - }); - await page.goto(`${origin}/?pane=${encodeURIComponent(pane)}`); - await page.locator(".conn-live").waitFor(); - await page.locator(".markdown-code .hl-code").first().waitFor(); - assert.equal(await page.locator(".markdown-code .hl-keyword, .markdown-code .hl-number").count(), 0, "chat code is plain"); - assert.equal(await page.locator(".markdown-code .hl-note").count(), 0, "plain by choice is not too long"); - await checkCodeFidelity("highlight-off"); - await page.getByRole("button", { name: "guide", exact: true }).click(); - const plainGuide = page.getByRole("dialog", { name: "guide.md", exact: true }); - await plainGuide.getByRole("heading", { name: "Guide", exact: true }).waitFor(); - await plainGuide.getByRole("button", { name: "Show source", exact: true }).click(); - await plainGuide.locator(".file-viewer-text .hl-line").first().waitFor(); - assert.equal(await plainGuide.locator(".hl-keyword, .hl-function").count(), 0, "the viewer's code is plain"); - assert.equal(await plainGuide.locator(".file-viewer-notice").count(), 0, "and no notice says it is too long"); - console.log("PASS Settings → Highlight code off shows code plain, in the chat and the viewer"); - await page.getByRole("button", { name: "Close file", exact: true }).click(); - await page.locator(".file-viewer").waitFor({ state: "hidden" }); - // the file found, then its body refused (the file gone since, a remote PC dropped): the error's // JSON must never show as the file's text await page.route("**/api/fs/file?**", (route) => route.fulfill({ status: 502, contentType: "application/json", body: JSON.stringify({ error: { code: "machine_unavailable", message: "The PC connection was interrupted" } }) }), { times: 1 }); diff --git a/scripts/fixed-sleeps.json b/scripts/fixed-sleeps.json new file mode 100644 index 000000000..2d04a08a3 --- /dev/null +++ b/scripts/fixed-sleeps.json @@ -0,0 +1,34 @@ +{ + "scripts/android-ime-regression.ts": 1, + "scripts/application-paging-regression.ts": 2, + "scripts/chat-size-regression.ts": 5, + "scripts/droplet-regression.ts": 5, + "scripts/key-bar-hold-repeat-regression.ts": 7, + "scripts/output-browser-qa.ts": 4, + "scripts/preferences-sync-regression.ts": 6, + "scripts/pty-compare.ts": 1, + "scripts/remote-bundle-smoke.ts": 1, + "scripts/terminal-arrows-clicks-regression.ts": 9, + "scripts/terminal-pinch-regression.ts": 5, + "scripts/ui-regression.ts": 13, + "server/attach-held.contract.test.ts": 5, + "server/collector.test.ts": 62, + "server/collector.ts": 1, + "server/herdr-profile-sync.test.ts": 1, + "server/herdr-update.test.ts": 1, + "server/index.ts": 8, + "server/machines.test.ts": 2, + "server/mirror.test.ts": 4, + "server/output.contract.test.ts": 6, + "server/pending-input.contract.test.ts": 2, + "server/prompt.contract.test.ts": 3, + "server/prompt.ts": 1, + "server/pty/session.test.ts": 4, + "server/push.test.ts": 3, + "server/reattach.contract.test.ts": 1, + "server/submit.contract.test.ts": 5, + "server/tailscale.test.ts": 1, + "server/updater.test.ts": 1, + "src/lib/pluginRun.ts": 1, + "src/lib/sheetHistory.test.ts": 1 +} diff --git a/scripts/fixed-sleeps.ts b/scripts/fixed-sleeps.ts new file mode 100644 index 000000000..6749c6c5b --- /dev/null +++ b/scripts/fixed-sleeps.ts @@ -0,0 +1,119 @@ +/** + * Fixed sleeps: waits that pass a set time instead of waiting for what they are after + * (AGENTS.md: "Wait with bounded polls and deadlines, never fixed sleeps"). A fixed sleep is + * too short on a slow CI runner and too long everywhere else. + * + * Not counted: + * - the pause between reads of a loop bounded by a deadline or a try count + * (`while (!done() && Date.now() < deadline) await Bun.sleep(25)`, `for (let i = 0; i < 40 && …; i++)`, + * or a loop whose body throws, asserts, returns or breaks on a deadline); + * - a timeout raced against the real event (`Promise.race([event, Bun.sleep(ms)])`); + * - `setTimeout(resolve, 0)`, which yields a turn rather than waiting; + * - a wait that is fixed on purpose and says so on its line or the line above: + * `// fixed wait: ` (proving that nothing arrives, a settle gap herdr needs). + * + * A local sleep helper (`const sleep = (ms) => new Promise(…setTimeout…)`, an alias of Bun.sleep, + * or setTimeout from node:timers/promises) is not counted where it is defined; each call of it is. + * + * scripts/conventions.test.ts holds every file to its count in scripts/fixed-sleeps.json, which + * may only shrink. `bun scripts/fixed-sleeps.ts` prints the current counts. + */ +import { files, lineOf, read, stripComments } from "./source-scan.ts"; + +/** Bun.sleep, or a Promise resolved by setTimeout (`r => setTimeout(r, n)`, `function (r) { setTimeout(() => r(), n) }`), not a 0 ms yield. */ +const SLEEP = /\bBun\.sleep(?:Sync)?\s*\(|\bnew Promise(?:<[^>]*>)?\(\s*(?:async\s*)?(?:function\s*)?\(?\s*(\w+)\s*\)?\s*(?:=>)?\s*\{?\s*(?:window\.|globalThis\.)?setTimeout\(\s*(?:\1|\(\s*\)\s*=>\s*\{?\s*\1\s*\(\s*\)\s*;?\s*\}?)\s*,(?!\s*0\s*\))/g; +/** A loop head bounded by a clock, a deadline or a try count (`i < 40`, `i < MAX_TRIES`, `attempt <= attempts`); a plain `i < list.length` is not. */ +const BOUND = /Date\.now\(\)|performance\.now\(\)|[Dd]eadline|\b\w+\s*<=?\s*(?:\d|[A-Z][A-Z0-9_]*\b|\w*(?:[Tt]ries|[Aa]ttempts|[Rr]etries|[Mm]ax|[Ll]imit)\w*)/; +/** `// fixed wait:` followed by the reason. */ +const MARKER = /\/\/\s*fixed wait:\s*\S/; + +/** The files the rule covers. Media takes are timed by hand. */ +export const sleepFiles = (): string[] => files("{server,src,shared,scripts,site,telemetry}/**/*.{ts,tsx,mjs,js}") + .filter((file) => !file.startsWith("scripts/film/") && !file.startsWith("scripts/readme-media/") && file !== "scripts/fixed-sleeps.ts" && file !== "scripts/conventions.test.ts"); + +/** Whether the innermost loop around `at` (within its function) is bounded. `code` has comments and strings blanked. */ +function inBoundedLoop(code: string, at: number): boolean { + for (let from = at; ;) { + let open = -1; + for (let i = from - 1, depth = 0; i >= 0; i--) { + if (code[i] === "}") depth += 1; + else if (code[i] === "{") { if (depth === 0) { open = i; break; } depth -= 1; } + } + if (open < 0) return false; + let close = open + 1; + for (let depth = 1; depth > 0 && close < code.length; close++) depth += code[close] === "{" ? 1 : code[close] === "}" ? -1 : 0; + const head = code.slice(code.lastIndexOf("\n", open - 1) + 1, open).trim(); + const body = code.slice(open, close); + const doWhile = /(?:^|[\s;}])do$/.test(head) ? /^\s*while\s*\(([^\n]*)/.exec(code.slice(close))?.[1] ?? "" : null; + if (doWhile !== null || /\b(?:while|for)\s*\(/.test(head)) { + return BOUND.test(doWhile ?? head) || (/Date\.now\(\)|performance\.now\(\)|deadline/i.test(body) && /\b(?:throw|return|break|assert|expect)\b/.test(body)); + } + // a function body: the loop, if any, would be inside it + if (/=>$|\)\s*(?::[^{]*)?$/.test(head) && !/\b(?:if|switch|catch|with)\s*\(/.test(head)) return false; + from = open; + } +} + +/** + * Names bound to a sleep in `code` (strings blanked; `source` keeps them, for the import): their + * definitions' offsets, and the helpers whose delay defaults to 0, so that a bare `tick()` yields. + */ +function sleepHelpers(code: string, source: string): { names: Set; definitions: Set; yields: Set } { + const names = new Set(); + const definitions = new Set(); + const yields = new Set(); + // `const sleep = (ms) => ` or `function sleep(ms) { return }`: a body that is only the sleep + const define = /(?:\b(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?(\([^)]*\)|\w+)\s*(?::[^=;\n]*)?=>\s*(?:\{\s*return\s+)?|\bfunction\s+(\w+)\s*(\([^)]*\))[^{;]*\{\s*return\s+)(?=\bBun\.sleep|\bnew Promise)/g; + for (const match of code.matchAll(define)) { + const at = match.index + match[0].length; + if (new RegExp(SLEEP.source, "y").exec(code.slice(at)) === null && !/^Bun\.sleep(?:Sync)?\s*\(/.test(code.slice(at))) continue; + const name = match[1] ?? match[3]!; + names.add(name); + definitions.add(at); + if (/=\s*0\s*\)$/.test(match[2] ?? match[4]!)) yields.add(name); + } + // `const wait = io.wait ?? Bun.sleep` + for (const match of code.matchAll(/\b(?:const|let|var)\s+(\w+)\s*=[^;\n]*\bBun\.sleep(?:Sync)?\b(?!\s*\()/g)) names.add(match[1]!); + for (const match of source.matchAll(/\bimport\s*\{([^}]*)\}\s*from\s*["'](?:node:)?timers\/promises["']/g)) { + const named = /\bsetTimeout(?:\s+as\s+(\w+))?\b/.exec(match[1]!); + if (named) names.add(named[1] ?? "setTimeout"); + } + return { names, definitions, yields }; +} + +/** The fixed sleeps in one file, as `file:line: text`. */ +export function fixedSleeps(file: string, text = read(file)): string[] { + const code = stripComments(text, { strings: true }); + const lines = text.split("\n"); + const found: string[] = []; + const helpers = sleepHelpers(code, stripComments(text)); + const calls = helpers.names.size ? [...code.matchAll(new RegExp(`(? !helpers.definitions.has(match.index)); + // a helper's own definition (`function sleep(`) is not a call, and `tick()` of a helper defaulting to 0 ms yields + const waits = [...sleeps, ...calls.filter((match) => !/\bfunction\s+$/.test(code.slice(Math.max(0, match.index - 20), match.index)) + && !(helpers.yields.has(match[0].replace(/\s*\($/, "")) && /^\(\s*\)/.test(code.slice(match.index + match[0].length - 1))))] + .sort((a, b) => a.index - b.index); + for (const match of waits) { + const line = lineOf(code, match.index); + const codeLine = code.split("\n")[line - 1]!; + if (/\bPromise\.race\s*\(/.test(codeLine)) continue; + if (MARKER.test(lines[line - 1]!) || MARKER.test(lines[line - 2] ?? "")) continue; + const before = codeLine.slice(0, match.index - (code.lastIndexOf("\n", match.index) + 1)); + if (/^\s*(?:while|for)\s*\(/.test(before) && BOUND.test(before)) continue; + if (inBoundedLoop(code, match.index)) continue; + found.push(`${file}:${line}: ${lines[line - 1]!.trim().slice(0, 160)}`); + } + return found; +} + +/** Fixed-sleep counts per file, files with none left out. */ +export function fixedSleepCounts(): Record { + const counts: Record = {}; + for (const file of sleepFiles()) { + const count = fixedSleeps(file).length; + if (count) counts[file] = count; + } + return counts; +} + +if (import.meta.main) console.log(JSON.stringify(fixedSleepCounts(), null, 2)); diff --git a/scripts/font-swap-demo-regression.ts b/scripts/font-swap-demo-regression.ts index 16d2b7f30..882002c2d 100644 --- a/scripts/font-swap-demo-regression.ts +++ b/scripts/font-swap-demo-regression.ts @@ -6,6 +6,7 @@ import { chromium, type Page } from "playwright-core"; import { appFaces } from "./app-faces.ts"; import panes from "../site/demo/fixtures/panes.json"; import { buildDemoApp } from "./demo-build.ts"; +import { chromePath } from "./chrome-path.ts"; // The late arrival of the app's faces (src/fonts/fonts.css, font-display: swap), on the unmodified // app over the demo's fixture transport: the font files are held back here as a slow link holds @@ -29,7 +30,11 @@ try { const index = join(app, "index.html"); const html = readFileSync(index, "utf8"); assert.match(html, /\n \n \n '); + await viteBuild({ configFile: join(repo, "vite.config.ts"), root, publicDir: false, logLevel: "warn", build: { outDir: output, emptyOutDir: true } }); + const policy = (await serveStatic("/")).headers.get("content-security-policy"); + assert.ok(policy, "build the app before its enforcing CSP regression"); + server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + const url = new URL(request.url); + const answer = await api(request, url); + if (answer) return answer; + if (url.pathname === "/favicon.ico") return new Response(null, { status: 204 }); + return new Response(Bun.file(join(output, url.pathname === "/" ? "index.html" : url.pathname.slice(1))), { headers: { "content-security-policy": policy } }); + } }); + const origin = `http://127.0.0.1:${server.port}`; + const moneyPatch = await (await repoDiffResponse("file", repos.main, new URLSearchParams({ scope: "workspace", path: "src/money.ts" }))).json() as { patch: string }; + assert.ok(moneyPatch.patch.includes("@@"), "the fixture's money.ts has a patch"); + // the headless shell, not the full browser of CHROME_PATH: its clipboard is in memory, so the real copy below is owned here + browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] }); + assert.match(browser.version(), /^\d+\./, "a browser launched"); + + for (const [width, theme] of [[1440, "dark"], [1440, "light"], [390, "dark"], [390, "light"]] as const) { + const label = `${width}px ${theme}`; + const phone = width === 390; + const context = await browser.newContext({ viewport: { width, height: phone ? 844 : 900 }, isMobile: phone, hasTouch: phone, colorScheme: theme }); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await context.addInitScript((theme) => { + // once: a reload keeps what the page saved since (the diff layout) + if (localStorage.getItem("herdr-web-ui:settings") === null) localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", theme })); + const record = window as unknown as { longTasks: number[] }; + record.longTasks = []; + new PerformanceObserver((list) => { for (const entry of list.getEntries()) record.longTasks.push(entry.duration); }).observe({ type: "longtask" }); + }, theme); + const page = await context.newPage(); + page.setDefaultTimeout(15_000); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() !== "error") return; + // the refusals asked for on purpose (a missing commit, a folder outside git) are logged by the browser as failed loads + if (/Failed to load resource/.test(message.text()) && /\/workspace\/diff\//.test(message.location().url ?? "") && /status of (?:404|409)/.test(message.text())) return; + errors.push(`console: ${message.text()}`); + }); + await page.goto(origin); + + // the working tree's changes, each with its status, counts and the repository they are in + await openFiles(page, "pane-main"); + const rows = await changeRows(page); + assert.deepEqual(rows, [ + "M assets/pic.bin Binary", + "M docs/new-name.md +1−1", + "M notes/dir with space/ünï 文件.txt +1−1", + "M src/big.ts +2,000−2,000", + "D src/gone.ts +0−3", + "M src/large.txt +8,000−8,000", + "M src/money.ts +3−2", + "? src/new.ts", + "M src/staged.ts +1−1", + ], `${label}: the working tree's changed files`); + assert.equal(await page.locator(".repo-changes-branch").textContent(), "main", `${label}: the branch`); + assert.equal(await page.locator(".repo-changes-root").textContent(), repos.main, `${label}: the repository root`); + await shot(page, `list-${width}-${theme}`); + + // a 10 000-line diff: a window of rows at the top, the middle and the end, with old and new numbers + const before = await page.evaluate(() => history.length); + await openChange(page, "src/big.ts"); + // a phone's Files sheet holds an entry of its own, which the diff takes over (lib/sheetHistory.ts): one Back closes the diff + assert.equal(await page.evaluate(() => history.length), phone ? before : before + 1, `${label}: the diff is a history entry`); + await page.getByRole("dialog", { name: "Changes to big.ts", exact: true }).waitFor(); + await waitRows(page); + const top = await mounted(page); + assert.ok(top.rows > 0 && top.rows < 600, `${label}: bounded rows at the top: ${JSON.stringify(top)}`); + assert.equal(top.first, 1, `${label}: the first row is line 1`); + assert.equal(top.style, "single", `${label}: unified by default`); + assert.ok(top.inView > 0, `${label}: rows in view at the top`); + const numbers = await page.evaluate(() => { + const shadow = document.querySelector(".repo-diff diffs-container")!.shadowRoot!; + const context = shadow.querySelector("[data-line][data-line-type='context'][data-alt-line]"); + const deletion = shadow.querySelector("[data-line][data-line-type='change-deletion']"); + const addition = shadow.querySelector("[data-line][data-line-type='change-addition']"); + const types: Record = {}; + for (const row of shadow.querySelectorAll("[data-line]")) types[row.dataset["lineType"] ?? "?"] = (types[row.dataset["lineType"] ?? "?"] ?? 0) + 1; + return { context: context && [context.dataset["line"], context.dataset["altLine"]], deletion: deletion?.dataset["line"] ?? deletion?.dataset["altLine"] ?? null, addition: addition?.dataset["line"] ?? addition?.dataset["altLine"] ?? null, types }; + }); + assert.deepEqual(numbers.context, ["1", "1"], `${label}: a context row carries its old and new numbers: ${JSON.stringify(numbers)}`); + assert.ok(numbers.deletion === "4" && numbers.addition === "4", `${label}: the first change is line 4 on both sides: ${JSON.stringify(numbers)}`); + const meta = await page.locator(".repo-diff-meta").first().textContent(); + assert.ok(meta?.includes("Modified") && meta.includes("+2,000") && meta.includes("−2,000") && meta.includes("Working tree"), `${label}: the header's status, counts and comparison: ${meta}`); + await shot(page, `top-${width}-${theme}`); + await scrollDiff(page, "middle"); + await rowReached(page, Math.floor(BIG_LINES / 2) - 1000, `${label} middle`); + const middle = await mounted(page); + assert.ok(middle.rows > 0 && middle.rows < 600 && middle.inView > 0 && middle.first > 1000, `${label}: bounded rows in the middle: ${JSON.stringify(middle)}`); + // the last line is blank and past the last hunk's context: the diff's last row is the line before it + await scrollDiff(page, "end"); + await rowReached(page, BIG_LINES - 1, `${label} end`); + const end = await mounted(page); + assert.ok(end.rows > 0 && end.rows < 600 && end.inView > 0, `${label}: bounded rows at the end: ${JSON.stringify(end)}`); + assert.equal(end.last, BIG_LINES - 1, `${label}: the last row is the last hunk's last context line: ${JSON.stringify(end)}`); + const widths = await page.evaluate(() => ({ page: document.documentElement.scrollWidth, viewport: document.documentElement.clientWidth, dialog: document.querySelector(".repo-diff-viewer")!.getBoundingClientRect().width })); + assert.ok(widths.page <= widths.viewport && widths.dialog <= widths.viewport, `${label}: nothing wider than the screen: ${JSON.stringify(widths)}`); + await shot(page, `end-${width}-${theme}`); + // the rows take the app's tokens, inside the shadow root + const colors = await page.evaluate(() => { + const probe = document.createElement("span"); + document.body.append(probe); + const resolve = (property: string, token: string) => { probe.style.setProperty(property, `var(${token})`); const value = getComputedStyle(probe).getPropertyValue(property); probe.style.removeProperty(property); return value; }; + const shadow = document.querySelector(".repo-diff diffs-container")!.shadowRoot!; + const row = shadow.querySelector("[data-line][data-line-type='context']")!; + const result = { text: resolve("color", "--text"), row: getComputedStyle(row).color, bg: resolve("background-color", "--bg"), pre: getComputedStyle(shadow.querySelector("pre")!).backgroundColor, host: getComputedStyle(document.querySelector(".repo-diff diffs-container")!).backgroundColor }; + probe.remove(); + return result; + }); + assert.equal(colors.row, colors.text, `${label}: the rows read in the app's text color: ${JSON.stringify(colors)}`); + assert.ok([colors.bg, "rgba(0, 0, 0, 0)"].includes(colors.pre) && [colors.bg, "rgba(0, 0, 0, 0)"].includes(colors.host), `${label}: no code background of the package's own: ${JSON.stringify(colors)}`); + // the package paints its gutter bars with the base colors verbatim and mixes the row tints from them + const tints = await page.evaluate(() => { + const probe = document.createElement("span"); + document.body.append(probe); + const resolve = (token: string) => { probe.style.backgroundColor = `var(${token})`; return getComputedStyle(probe).backgroundColor; }; + const shadow = document.querySelector(".repo-diff diffs-container")!.shadowRoot!; + const number = (type: string) => getComputedStyle(shadow.querySelector(`[data-column-number][data-line-type='${type}']`)!).color; + const row = (type: string) => getComputedStyle(shadow.querySelector(`[data-line][data-line-type='${type}']`)!).backgroundColor; + const result = { inserted: resolve("--syntax-inserted"), deleted: resolve("--syntax-deleted"), additionNumber: number("change-addition"), deletionNumber: number("change-deletion"), additionRow: row("change-addition"), deletionRow: row("change-deletion"), contextRow: row("context") }; + probe.remove(); + return result; + }); + assert.equal(tints.additionNumber, tints.inserted, `${label}: an added row's number is the inserted token: ${JSON.stringify(tints)}`); + assert.equal(tints.deletionNumber, tints.deleted, `${label}: a removed row's number is the deleted token: ${JSON.stringify(tints)}`); + assert.ok(tints.additionRow !== tints.contextRow && tints.deletionRow !== tints.contextRow && tints.additionRow !== tints.deletionRow, `${label}: added and removed rows are each tinted: ${JSON.stringify(tints)}`); + assert.ok((await page.evaluate(() => (window as unknown as { longTasks: number[] }).longTasks)).every((task) => task < 1_000), `${label}: no long task of a second`); + assert.deepEqual(errors, [], `${label}: no errors while opening the big diff`); + console.log(`PASS ${label}: a 10 000-line diff as a bounded window of rows at the top, the middle and the end (rows ${top.rows}/${middle.rows}/${end.rows}), in the app's palette`); + + // unified or split: a setting kept across a reload; a phone draws one column and says why + await scrollDiff(page, "top"); + const split = page.getByRole("button", { name: "Split", exact: true }); + if (phone) { + assert.equal(await split.isDisabled(), true, `${label}: Split is declined on a phone`); + await page.evaluate(() => window.repoDiffQA.settings({ diffStyle: "split" })); + await page.locator(".repo-diff-notice").filter({ hasText: "Split view needs a wider screen; showing one column." }).waitFor(); + assert.equal((await mounted(page)).style, "single", `${label}: still one column with the setting on split`); + await shot(page, `split-declined-${width}-${theme}`); + await page.evaluate(() => window.repoDiffQA.settings({ diffStyle: "unified" })); + await page.locator(".repo-diff-notice").filter({ hasText: "Split view needs a wider screen" }).waitFor({ state: "detached" }); + await closeDiff(page); + } else { + await split.click(); + await page.locator(".repo-diff[data-style='split']").waitFor(); + await page.waitForFunction(() => document.querySelector(".repo-diff diffs-container")?.shadowRoot?.querySelector("pre")?.getAttribute("data-diff-type") === "split"); + const splitRows = await mounted(page); + assert.ok(splitRows.rows > 0 && splitRows.rows < 600 && splitRows.inView > 0, `${label}: bounded split rows: ${JSON.stringify(splitRows)}`); + assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem("herdr-web-ui:settings")!).diffStyle), "split", `${label}: the layout is a saved setting`); + await shot(page, `split-${width}-${theme}`); + await page.reload(); + await page.getByRole("dialog", { name: "Changes to big.ts", exact: true }).waitFor(); + await waitRows(page); + assert.equal((await mounted(page)).style, "split", `${label}: a reload keeps the diff open, split`); + await page.getByRole("button", { name: "Unified", exact: true }).click(); + await page.waitForFunction(() => document.querySelector(".repo-diff diffs-container")?.shadowRoot?.querySelector("pre")?.getAttribute("data-diff-type") === "single"); + // the reload lost the Files list beneath: Close steps back off the diff's entry, and the list is opened again + await closeDiff(page); + await openFiles(page, "pane-main"); + await changeRows(page); + } + console.log(`PASS ${label}: ${phone ? "a phone declines Split and says so" : "Split is kept across a reload, and Unified restored"}`); + + // the context folded to a line around each change, the numbers kept, and shown whole again + await openChange(page, "src/money.ts"); + await waitRows(page); + const whole = await page.evaluate(() => { + const shadow = document.querySelector(".repo-diff diffs-container")!.shadowRoot!; + return { rows: shadow.querySelectorAll("[data-line]").length, separators: shadow.querySelectorAll("[data-separator='line-info']").length, secondChange: shadow.querySelectorAll("[data-line][data-line-type='change-addition']")[2]?.dataset["line"] }; + }); + await page.getByRole("button", { name: "Fold context", exact: true }).click(); + await page.locator(".repo-diff[data-folded]").waitFor(); + // the package re-renders its rows after the fold, not with it + await page.waitForFunction((count) => { const rows = document.querySelector(".repo-diff diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0; return rows > 0 && rows !== count; }, whole.rows); + const folded = await page.evaluate(() => { + const shadow = document.querySelector(".repo-diff diffs-container")!.shadowRoot!; + return { rows: shadow.querySelectorAll("[data-line]").length, separators: shadow.querySelectorAll("[data-separator='line-info']").length, secondChange: shadow.querySelectorAll("[data-line][data-line-type='change-addition']")[2]?.dataset["line"], folded: [...shadow.querySelectorAll("[data-unmodified-lines]")].map((node) => node.textContent) }; + }); + assert.ok(folded.rows < whole.rows, `${label}: fewer rows folded: ${JSON.stringify({ whole, folded })}`); + assert.ok(folded.separators >= whole.separators, `${label}: the folded lines are counted in separators: ${JSON.stringify({ whole, folded })}`); + assert.equal(folded.secondChange, whole.secondChange, `${label}: the second hunk keeps its line numbers: ${JSON.stringify({ whole, folded })}`); + assert.ok(folded.folded.some((text) => /\d+ unmodified lines/.test(text ?? "")), `${label}: a separator says how many lines are folded: ${JSON.stringify(folded.folded)}`); + await shot(page, `folded-${width}-${theme}`); + await page.getByRole("button", { name: "Fold context", exact: true }).click(); + await page.locator(".repo-diff:not([data-folded])").waitFor(); + await page.waitForFunction((count) => (document.querySelector(".repo-diff diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0) === count, whole.rows); + assert.equal(await page.evaluate(() => document.querySelector(".repo-diff diffs-container")!.shadowRoot!.querySelectorAll("[data-line]").length), whole.rows, `${label}: shown whole again`); + console.log(`PASS ${label}: the context folds to a line around each change and unfolds, numbers kept`); + + // native selection and a real copy: the rows' text alone, blank lines kept, no gutter numbers + if (!phone) { + // the worker's colors redraw the rows, plain until then, and a redraw drops a selection made on the plain ones + await page.locator(".repo-diff[data-colored]").waitFor(); + await page.waitForFunction(() => (document.querySelector(".repo-diff diffs-container")?.shadowRoot?.querySelectorAll("[data-line] span[style*='--herdr-code-token']").length ?? 0) > 0); + const expected = hunkRowTexts(moneyPatch.patch, 7); + assert.ok(expected.includes(""), "the selected rows span a blank line"); + const selected = await selectRows(page, 0, 6); + assert.equal(selected, expected.join("\n"), `${label}: the selection is the rows' text with its blank line`); + assert.equal(await copied(page), expected.join("\n"), `${label}: the real copy is the same text`); + await page.evaluate(() => window.getSelection()?.removeAllRanges()); + console.log(`PASS ${label}: native selection and a real copy keep blank lines and leave the gutters out`); + } + + // the file itself opens on the owning PC, above the diff; Escape closes one surface at a time + await page.getByRole("button", { name: "Open file", exact: true }).click(); + const file = page.getByRole("dialog", { name: "money.ts", exact: true }); + await file.waitFor(); + assert.ok(requests.some((path) => path === "/api/fs/stat"), `${label}: the file is read through the file routes`); + await file.locator(".file-viewer-text .hl-line").first().waitFor(); + await page.keyboard.press("Escape"); + await file.waitFor({ state: "detached" }); + await page.locator(".repo-diff-viewer").waitFor(); + await page.keyboard.press("Escape"); + await page.locator(".repo-diff-viewer").waitFor({ state: "detached" }); + await page.getByRole("dialog", { name: "Files", exact: true }).waitFor(); + console.log(`PASS ${label}: Open file shows the file above the diff; Escape closes the file, then the diff, and the list stays`); + + // Back and Close return the focus to the row that opened the diff + const row = page.locator(".repo-change").filter({ has: page.locator(".repo-change-path", { hasText: "src/money.ts" }) }).first(); + await row.focus(); + await page.keyboard.press("Enter"); + await page.locator(".repo-diff-viewer").waitFor(); + await page.evaluate(() => window.history.back()); + await page.locator(".repo-diff-viewer").waitFor({ state: "detached" }); + assert.equal(await page.evaluate(() => document.activeElement?.querySelector(".repo-change-path")?.textContent), "src/money.ts", `${label}: Back returns the focus to the row`); + await row.click(); + await page.locator(".repo-diff-viewer").waitFor(); + await closeDiff(page); + assert.equal(await page.evaluate(() => document.activeElement?.querySelector(".repo-change-path")?.textContent), "src/money.ts", `${label}: Close returns the focus to the row`); + console.log(`PASS ${label}: Back and Close each close the diff alone and return the focus to its row`); + + // the kinds a diff cannot draw, and the kinds it draws from one side + await openChange(page, "assets/pic.bin"); + assert.equal(await note(page), "This is a binary file; there are no lines to compare.", `${label}: a binary file says so`); + assert.ok((await page.locator(".repo-diff-meta").first().textContent())?.includes("Binary"), `${label}: and is marked binary`); + await shot(page, `binary-${width}-${theme}`); + await closeDiff(page); + await openChange(page, "src/large.txt"); + assert.equal(await note(page), "This change is larger than 1 MB and is not shown here.", `${label}: a change past the cap says so with the cap`); + assert.equal(await page.getByRole("button", { name: "Copy patch", exact: true }).count(), 0, `${label}: nothing to copy of it`); + await closeDiff(page); + await openChange(page, "src/new.ts"); + await waitRows(page); + const untracked = await page.evaluate(() => { const shadow = document.querySelector(".repo-diff diffs-container")!.shadowRoot!; return { additions: shadow.querySelectorAll("[data-line][data-line-type='change-addition']").length, deletions: shadow.querySelectorAll("[data-line][data-line-type='change-deletion']").length, context: shadow.querySelectorAll("[data-line][data-line-type='context']").length }; }); + assert.deepEqual(untracked, { additions: 3, deletions: 0, context: 0 }, `${label}: an untracked file is all additions: ${JSON.stringify(untracked)}`); + assert.ok((await page.locator(".repo-diff-meta").first().textContent())?.includes("Untracked"), `${label}: and marked untracked`); + await closeDiff(page); + await openChange(page, "src/gone.ts"); + await waitRows(page); + const deleted = await page.evaluate(() => { const shadow = document.querySelector(".repo-diff diffs-container")!.shadowRoot!; return { additions: shadow.querySelectorAll("[data-line][data-line-type='change-addition']").length, deletions: shadow.querySelectorAll("[data-line][data-line-type='change-deletion']").length }; }); + assert.deepEqual(deleted, { additions: 0, deletions: 3 }, `${label}: a deleted file is all deletions: ${JSON.stringify(deleted)}`); + assert.equal(await page.getByRole("button", { name: "Open file", exact: true }).isDisabled(), true, `${label}: a deleted file cannot be opened`); + await closeDiff(page); + await openChange(page, "notes/dir with space/ünï 文件.txt"); + await waitRows(page); + assert.equal(await page.getByRole("dialog", { name: "Changes to ünï 文件.txt", exact: true }).count(), 1, `${label}: a path with spaces and Unicode opens`); + await closeDiff(page); + console.log(`PASS ${label}: binary, too large, untracked, deleted and Unicode paths each show what they are`); + + // the index against HEAD: a rename with its old path; two commits by name; a commit that is not there + await page.getByRole("button", { name: "Staged", exact: true }).click(); + const staged = await changeRows(page, "staged"); + assert.deepEqual(staged, ["R docs/old-name.md → docs/new-name.md +1−1", "M src/staged.ts +1−1"], `${label}: the staged changes: ${JSON.stringify(staged)}`); + await openChange(page, "docs/new-name.md"); + await waitRows(page); + const renamed = await page.locator(".repo-diff-meta").first().textContent(); + assert.ok(renamed?.includes("Renamed") && renamed.includes("Staged"), `${label}: a renamed file's header: ${renamed}`); + assert.equal(await page.locator(".repo-diff-meta").first().getAttribute("title"), "Renamed from docs/old-name.md", `${label}: the old path is the tooltip`); + await closeDiff(page); + await page.getByRole("button", { name: "Commits", exact: true }).click(); + await page.getByLabel("From commit", { exact: true }).fill("HEAD~1"); + await page.getByRole("button", { name: "Compare", exact: true }).click(); + const commits = await changeRows(page, "commit"); + assert.deepEqual(commits, ["A CHANGELOG.md +3−0", "M src/money.ts +1−1"], `${label}: the commits' changes: ${JSON.stringify(commits)}`); + await openChange(page, "src/money.ts"); + await waitRows(page); + assert.match((await page.locator(".repo-diff-compare").textContent()) ?? "", /^[0-9a-f]{7}\.\.[0-9a-f]{7}$/, `${label}: a commit comparison is named by its hashes`); + await closeDiff(page); + await page.getByLabel("From commit", { exact: true }).fill("nope"); + await page.getByRole("button", { name: "Compare", exact: true }).click(); + await page.locator(".repo-changes-error").filter({ hasText: "No commit by that name." }).waitFor(); + await closeFiles(page); + console.log(`PASS ${label}: staged renames, commit comparisons and a missing commit each read right`); + + // a conflicted file on a detached HEAD, and a folder that is no repository + await openFiles(page, "pane-conflict"); + assert.deepEqual(await changeRows(page), ["U src/conflict.ts"], `${label}: a conflict is listed`); + assert.equal(await page.locator(".repo-changes-branch").textContent(), "Detached HEAD", `${label}: a detached HEAD says so`); + await openChange(page, "src/conflict.ts"); + assert.equal(await note(page), "This file has a merge conflict; resolve it in the terminal first.", `${label}: a conflict says so`); + await closeDiff(page); + await closeFiles(page); + await openFiles(page, "pane-plain"); + await page.locator(".repo-changes-error").filter({ hasText: "This folder is not in a git repository." }).waitFor(); + await closeFiles(page); + assert.deepEqual(errors, [], `${label}: no errors at the end`); + console.log(`PASS ${label}: a conflict, a detached HEAD and a folder outside git are each explained`); + await context.close(); + } + + // a remote PC's repository reads through its bridge, the file too + { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + await context.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", theme: "dark" }))); + const page = await context.newPage(); + page.setDefaultTimeout(15_000); + await page.goto(origin); + requests.length = 0; + await openFiles(page, "pane-main", "remote-fixture"); + await changeRows(page); + await openChange(page, "src/money.ts"); + await waitRows(page); + await page.getByRole("button", { name: "Open file", exact: true }).click(); + await page.getByRole("dialog", { name: "money.ts", exact: true }).waitFor(); + assert.deepEqual(requests.filter((path) => path.includes("/workspace/diff/")), ["/api/machines/remote-fixture/workspace/diff/files", "/api/machines/remote-fixture/workspace/diff/file"], `remote diff requests: ${requests.join(", ")}`); + assert.ok(requests.some((path) => path === "/api/machines/remote-fixture/fs/stat"), `remote file requests: ${requests.join(", ")}`); + await context.close(); + console.log("PASS a remote PC's changes and files read through its bridge"); + } + + // the static demo answers the routes from its fixture: the list, a diff and no missing route + { + const app = join(root, "demo"); + await buildDemoApp(app); + const index = join(app, "index.html"); + writeFileSync(index, readFileSync(index, "utf8").replace(/\n ', { headers: { "Content-Type": "text/html" } }); }, }); -const browser = await chromium.launch({ executablePath: process.env["CHROME_PATH"] ?? "/opt/google/chrome/chrome", headless: true }); +const browser = await chromium.launch({ executablePath: chromePath(), headless: true }); try { const page = await browser.newPage(); page.setDefaultTimeout(10_000); diff --git a/scripts/terminal-draft-regression.ts b/scripts/terminal-draft-regression.ts index c5fb4db00..788664a8d 100644 --- a/scripts/terminal-draft-regression.ts +++ b/scripts/terminal-draft-regression.ts @@ -14,11 +14,11 @@ export async function checkHeldDraftPaneSwitch(browser: Browser, origin: string, }, [paneA, paneB]); await context.routeWebSocket(/\/ws(?:\?|$)/, (socket) => socket.close({ code: 1000, reason: "offline for the held-input check" })); const page = await context.newPage(); - const held = page.locator(".terminal-banner-warning .draft-held"); + const held = page.locator(".pane-frame.is-current .terminal-banner-warning .draft-held"); const select = (paneId: string) => page.locator(`.pane-select[title^="${paneId} —"]`).click(); await page.goto(`${origin}/?pane=${encodeURIComponent(paneA)}`); - await page.locator(".terminal-banner-warning").waitFor(); - await page.locator(".xterm-helper-textarea").focus(); + await page.locator(".pane-frame.is-current .terminal-banner-warning").waitFor(); + await page.locator(".pane-frame.is-current .xterm-helper-textarea").focus(); await page.keyboard.type("echo held-a"); await held.filter({ hasText: "echo held-a" }).waitFor({ timeout: 10_000 }); await select(paneB); @@ -42,14 +42,14 @@ export async function checkHeldDraftPaneSwitch(browser: Browser, origin: string, server.onMessage((message) => { if (!String(message).includes('"input-ready"')) socket.send(message); }); }); const page = await notReady.newPage(); - const held = page.locator(".terminal-banner-draft .draft-text"); + const held = page.locator(".pane-frame.is-current .terminal-banner-draft .draft-text"); const select = (paneId: string) => page.locator(`.pane-select[title^="${paneId} —"]`).click(); await page.goto(`${origin}/?pane=${encodeURIComponent(paneA)}`); await page.locator(".conn-live").waitFor(); - await page.locator(".xterm-helper-textarea").focus(); + await page.locator(".pane-frame.is-current .xterm-helper-textarea").focus(); await page.keyboard.type("echo held-b"); await held.filter({ hasText: "echo held-b" }).waitFor({ timeout: 10_000 }); - await page.locator(".terminal-banner-draft .draft-send").click(); + await page.locator(".pane-frame.is-current .terminal-banner-draft .draft-send").click(); await held.filter({ hasText: "echo held-b" }).waitFor({ timeout: 10_000 }); await select(paneB); await held.waitFor({ state: "detached", timeout: 10_000 }); diff --git a/scripts/terminal-file-input-regression.ts b/scripts/terminal-file-input-regression.ts index 1f6f8f7bf..bf98a4b4e 100644 --- a/scripts/terminal-file-input-regression.ts +++ b/scripts/terminal-file-input-regression.ts @@ -16,7 +16,7 @@ export async function checkTerminalFileInput(page: Page, socket: WebSocket): Pro socket.on("framesent", listener); }); await page.locator(".conn-live").waitFor(); - const host = page.locator(".pane-terminal"); + const host = page.locator(".pane-frame.is-current .pane-terminal"); const path = "/Users/test/Downloads/monthly-report.json"; const unbracket = (text: string): string => text.replace(/^\x1b\[200~/, "").replace(/\x1b\[201~$/, ""); const dropped = input(); @@ -29,7 +29,7 @@ export async function checkTerminalFileInput(page: Page, socket: WebSocket): Pro await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: new URL(page.url()).origin }); await page.evaluate((text) => navigator.clipboard.writeText(text), path); - await page.locator(".xterm-helper-textarea").focus(); + await host.locator(".xterm-helper-textarea").focus(); const pasted = input(); await page.keyboard.press(process.platform === "darwin" ? "Meta+v" : "Control+v"); assert.equal(unbracket(await pasted), path); diff --git a/scripts/terminal-file-links-regression.ts b/scripts/terminal-file-links-regression.ts index b3343e6cd..73c4cb56e 100644 --- a/scripts/terminal-file-links-regression.ts +++ b/scripts/terminal-file-links-regression.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { chromium } from "playwright-core"; import { createServer } from "../server/index.ts"; import { paneSendText, workspaceCreate, workspaceClose } from "../server/herdr/client.ts"; +import { chromePath } from "./chrome-path.ts"; const cwd = process.cwd(); const shortUri = "file:///etc/hosts"; @@ -18,7 +19,7 @@ let browser: Awaited> | undefined; try { server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir }); browser = await chromium.launch({ - executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", + executablePath: chromePath(), headless: true, }); for (const width of [1280, 390]) { diff --git a/scripts/terminal-input-regression.ts b/scripts/terminal-input-regression.ts index 5e224d712..1d12df262 100644 --- a/scripts/terminal-input-regression.ts +++ b/scripts/terminal-input-regression.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import type { Browser } from "playwright-core"; import { openSettingsPage } from "./settings-page.ts"; +import { chromePath } from "./chrome-path.ts"; export async function checkTerminalInput(browser: Browser, origin: string, pane: string, otherPane: string): Promise { const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); @@ -13,23 +14,45 @@ export async function checkTerminalInput(browser: Browser, origin: string, pane: if (!localStorage.getItem("herdr-web-ui:settings")) localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", terminalInputMode: "line" })); }); const sent: any[] = []; + const resizes: any[] = []; let acknowledge: (() => void) | undefined; + let refuse: (() => void) | undefined; let ready: (() => void) | undefined; let delayReady = false; + let refuseConnections = false; + let currentSocket: { close: () => Promise } | undefined; + // the page's latest connection, to play output the pane did not write (a keyboard mode it asks for) + let toPage: ((frame: unknown) => void) | undefined; + // liveness pings held back from the server, so a probe stays in flight until they are let through + let holdPings = false; + const heldPings: string[] = []; + let toServer: ((raw: string) => void) | undefined; + // the snapshot names what the bridge answers, pings among it; it can follow input-ready by a few ms + let snapshotSent = false; await page.routeWebSocket(/\/ws(?:\?|$)/, (socket) => { + if (refuseConnections) { void socket.close(); return; } + currentSocket = socket; + toPage = (frame) => socket.send(JSON.stringify(frame)); const server = socket.connectToServer(); + toServer = (raw) => server.send(raw); server.onMessage((raw) => { const frame = JSON.parse(String(raw)); if (delayReady && frame.type === "input-ready") ready = () => socket.send(raw); else socket.send(raw); + if (frame.type === "snapshot") snapshotSent = true; }); socket.onMessage((raw) => { const frame = JSON.parse(String(raw)); if (frame.type === "submit") { sent.push(frame); acknowledge = () => socket.send(JSON.stringify({ type: "submit-result", id: frame.id, pane_id: frame.pane_id, ok: true })); + refuse = () => socket.send(JSON.stringify({ type: "submit-result", id: frame.id, pane_id: frame.pane_id, ok: false, code: "agent_blocked", message: "" })); } else if (frame.type === "input") sent.push(frame); - else server.send(raw); + else if (frame.type === "ping" && holdPings) heldPings.push(String(raw)); + else { + if (frame.type === "resize") resizes.push(frame); + server.send(raw); + } }); }); const line = page.getByRole("textbox", { name: "Terminal input line", exact: true }); @@ -49,7 +72,128 @@ export async function checkTerminalInput(browser: Browser, origin: string, pane: await page.goto(`${origin}/?pane=${encodeURIComponent(pane)}`); await page.getByTitle("Live terminal (⌘⇧J)", { exact: true }).click(); assert.equal(await page.locator(".key-bar").isVisible(), false, "a desktop shows no key bar under the terminal"); + // a phone keyboard's autocorrect would swap a flag or path for a dictionary word before Send + assert.deepEqual(await line.evaluate((box) => [box.getAttribute("autocorrect"), box.getAttribute("spellcheck"), box.getAttribute("autocapitalize")]), ["off", "false", "off"]); assert.equal(await page.getByRole("button", { name: "Type straight into the terminal", exact: true }).count(), 0); + + // A liveness probe refuses input without a frame to say so: on a quiet pane the 1 s poll reads + // it, so the terminal shows it waits instead of looking ready while typing goes to the draft. + const waiting = page.locator(".terminal-banner", { hasText: "Waiting for terminal input…" }); + // the banner is drawn only on a connected pane: before the socket opens it is absent too, and + // a wake then finds nothing to probe + await page.locator(".conn-live").waitFor(); + await waiting.waitFor({ state: "hidden" }); + // nor does a wake probe before the snapshot has said the bridge answers pings + await until(() => snapshotSent); + holdPings = true; + await page.evaluate(() => window.dispatchEvent(new Event("online"))); + await until(() => heldPings.length > 0); + const probeAt = Date.now(); + await waiting.waitFor({ timeout: 2_000 }); + assert(Date.now() - probeAt < 1_800, "the poll reads the refused input within about a second"); + // the pong ends the probe: input reads ready again + holdPings = false; + for (const raw of heldPings.splice(0)) toServer!(raw); + await waiting.waitFor({ state: "hidden", timeout: 2_000 }); + + // The line grows over the grid's last rows: neither its growth nor its collapse after Send + // changes the grid's height, which is the shared pty's size. + await line.waitFor(); + const gridHeight = () => page.locator(".pane-terminal").evaluate((element) => element.getBoundingClientRect().height); + const height = (selector: string) => page.locator(selector).evaluate((element) => element.getBoundingClientRect().height); + const frame = () => page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await page.evaluate(() => { + const grid = document.querySelector(".pane-terminal")!; + (window as any).gridResized = 0; + let first = true; + new ResizeObserver(() => { if (first) first = false; else (window as any).gridResized += 1; }).observe(grid); + }); + await frame(); + const restingGrid = await gridHeight(); + const resizesBefore = resizes.length; + const restingLine = await height(".terminal-input"); + await line.click(); + await page.keyboard.type("first line"); + await page.keyboard.press("Shift+Enter"); + await page.keyboard.type("second line"); + await page.keyboard.press("Shift+Enter"); + await page.keyboard.type("third line"); + await until(async () => (await line.getAttribute("rows")) === "3"); + await frame(); + assert(await height(".terminal-input") > restingLine + 20, "three lines grow the line"); + if (process.env.UI_EVIDENCE_DIR) { + const { mkdirSync } = await import("node:fs"); + mkdirSync(process.env.UI_EVIDENCE_DIR, { recursive: true }); + await page.screenshot({ path: `${process.env.UI_EVIDENCE_DIR}/terminal-input-grown.png` }); + } + assert.equal(await gridHeight(), restingGrid, "a growing input line keeps the grid's height"); + const submitsBefore = sent.length; + await page.getByRole("button", { name: "Send to the terminal", exact: true }).click(); + await until(() => sent.length > submitsBefore && !!acknowledge); + assert.equal(sent.at(-1).text, "first line\nsecond line\nthird line"); + const sentPane = sent.at(-1).pane_id; + acknowledge!(); + await until(async () => (await line.inputValue()) === "" && (await line.getAttribute("rows")) === "1"); + await frame(); + assert.equal(await gridHeight(), restingGrid, "the line's collapse after Send keeps the grid's height"); + assert.equal(await page.evaluate(() => (window as any).gridResized), 0, "the grid's observer never fired"); + assert(Math.abs(await height(".terminal-input") - await height(".terminal-input-slot")) < 1, "a one-row line covers no grid row"); + // what PaneTerminal's observer would have sent comes 120 ms after a change, and none happened + assert.equal(resizes.length, resizesBefore, "typing three lines and Send send no resize"); + sent.length = 0; + acknowledge = undefined; + + // a note under the one-row line (a refused send) floats too, and so does its going + await line.fill("refused"); + await page.getByRole("button", { name: "Send to the terminal", exact: true }).click(); + await until(() => !!refuse); + refuse!(); + const note = page.locator(".terminal-input-note"); + await note.waitFor(); + await frame(); + assert.equal(await gridHeight(), restingGrid, "a note under the line keeps the grid's height"); + await line.fill(""); + await note.waitFor({ state: "detached" }); + await frame(); + assert.equal(await gridHeight(), restingGrid, "the note's going keeps the grid's height"); + assert.equal(await page.evaluate(() => (window as any).gridResized), 0, "the grid's observer never fired for the note"); + sent.length = 0; + acknowledge = refuse = undefined; + + // the slot's row is a hand-made calc of the line's metrics: it must match at either density + const density = await page.evaluate(() => document.documentElement.getAttribute("data-density")); + await page.evaluate(() => document.documentElement.setAttribute("data-density", "compact")); + await frame(); + assert(Math.abs(await height(".terminal-input") - await height(".terminal-input-slot")) < 1, "a one-row line covers no grid row at the compact density"); + await page.evaluate((density) => { if (density === null) document.documentElement.removeAttribute("data-density"); else document.documentElement.setAttribute("data-density", density); }, density); + + // a short pane: the grown line reaches the grid's top at most, and its text scrolls past that + await page.setViewportSize({ width: 1280, height: 160 }); + await frame(); + const surfaceTop = () => page.locator(".terminal-surface").evaluate((element) => element.getBoundingClientRect().top); + const shortSurface = await height(".terminal-surface"); + assert(shortSurface < 100, `the pane is shorter than a grown line (${shortSurface}px)`); + await line.fill("one\ntwo\nthree\nfour"); + await until(async () => (await line.getAttribute("rows")) === "4"); + await frame(); + const lineTop = await page.locator(".terminal-input").evaluate((element) => element.getBoundingClientRect().top); + assert(lineTop >= await surfaceTop() - 1, `the grown line stays under the grid's top (${lineTop} < ${await surfaceTop()})`); + assert(await line.evaluate((element) => element.scrollHeight > element.clientHeight), "the capped line scrolls its text"); + // the banners stay over the capped line, where it reaches the grid's top: their buttons take the tap + toPage!({ type: "error", code: "input_failed", pane_id: sentPane, message: "Not sent: test banner" }); + const dismiss = page.locator(".terminal-banners").getByRole("button", { name: "Dismiss", exact: true }); + await dismiss.waitFor(); + const box = (await dismiss.boundingBox())!; + const lineBox = (await page.locator(".terminal-input").boundingBox())!; + assert(box.y + box.height / 2 > lineBox.y, "the banner lies within the grown line's box"); + assert(await page.evaluate(([x, y]) => !!document.elementFromPoint(x, y)?.closest(".terminal-banners"), [box.x + box.width / 2, box.y + box.height / 2]), + "a banner's button stays above the grown input line"); + await dismiss.click(); + await dismiss.waitFor({ state: "detached" }); + await line.fill(""); + await page.setViewportSize({ width: 1280, height: 800 }); + await until(async () => (await line.getAttribute("rows")) === "1"); + await line.fill("draft 한글 😀"); await remountLine(); assert.equal(await line.inputValue(), "draft 한글 😀"); @@ -63,10 +207,133 @@ export async function checkTerminalInput(browser: Browser, origin: string, pane: await line.waitFor(); assert.equal(await line.inputValue(), "draft 한글 😀"); + // `note` is the input line's note locator declared above + // one read: a note that goes between a count and a textContent would wait out the timeout + const noteText = () => note.evaluateAll((elements) => elements[0]?.textContent ?? null); + const sendButton = page.getByRole("button", { name: "Send to the terminal", exact: true }); + // A real composition (Chrome's IME path, as Gboard composes every word): the Send keeps focus, + // so the tap itself must finish the word, and it sends the committed line at once. + await line.fill("real 한"); + await line.focus(); + const cdp = await page.context().newCDPSession(page); + await cdp.send("Input.imeSetComposition", { text: "글", selectionStart: 1, selectionEnd: 1 }); + await until(async () => (await line.inputValue()) === "real 한글"); + // a word committed inside the tap sends with no "Finishing the word…" alert ever mounted + await page.evaluate(() => { + (window as any).noteAdded = 0; + (window as any).noteObserver = new MutationObserver((records) => { + for (const record of records) for (const node of record.addedNodes) { + if (node instanceof Element && (node.matches(".terminal-input-note") || node.querySelector(".terminal-input-note"))) (window as any).noteAdded += 1; + } + }); + (window as any).noteObserver.observe(document.body, { childList: true, subtree: true }); + }); + await sendButton.click(); + await until(async () => sent.length === 1 || (await noteText()) === "Finish the word, then send."); + assert.equal(sent.length, 1, "a Send during a real composition finishes the word and sends"); + assert.equal(sent[0].text, "real 한글"); + assert.equal(await line.evaluate((element) => element === document.activeElement), true, "the line keeps focus, so the keyboard stays up"); + await page.waitForTimeout(50); + assert.equal(await page.evaluate(() => { (window as any).noteObserver.disconnect(); return (window as any).noteAdded; }), 0, "a word committed inside the tap mounts no note"); + await cdp.detach(); + acknowledge!(); + await until(async () => (await line.inputValue()) === ""); + acknowledge = undefined; + // A keyboard that commits on its own within the wait: the line as committed is sent, once, + // even when the committing input lands in the same task as compositionend. + await line.fill("commit 한"); await line.dispatchEvent("compositionstart", { data: "" }); - await page.getByRole("button", { name: "Send to the terminal", exact: true }).click(); + await sendButton.click(); + await until(async () => (await note.textContent()) === "Finishing the word…"); + assert.equal(sent.length, 1, "unfinished composition must not submit via button"); + await line.evaluate((element: HTMLTextAreaElement) => { + const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!; + element.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "글" })); + setValue.call(element, "commit 한글"); + element.dispatchEvent(new InputEvent("input", { bubbles: true, data: "글", inputType: "insertFromComposition" })); + }); + await until(() => sent.length === 2); + assert.equal(sent[1].text, "commit 한글", "the Send tapped during composition sends the committed line"); + acknowledge!(); + await until(async () => (await line.inputValue()) === ""); + acknowledge = undefined; + // The preedit is deleted (or the keyboard cancels it) before it commits: the text Send sends + // nothing, and above all no bare Enter that could accept a prompt's default. + await line.fill("한"); + await line.dispatchEvent("compositionstart", { data: "" }); + await sendButton.click(); + await until(async () => (await noteText()) === "Finishing the word…"); + await line.evaluate((element: HTMLTextAreaElement) => { + const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!; + element.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "" })); + setValue.call(element, ""); + element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteCompositionText" })); + }); + await page.waitForTimeout(400); + assert.equal(sent.length, 2, "an empty commit after a text Send sends no Enter"); + assert.equal(await line.inputValue(), ""); + // Compositions that keep chaining after the tap cannot stretch its wait past the deadline. + await line.fill("chain 한"); + await line.dispatchEvent("compositionstart", { data: "" }); + await sendButton.click(); + await until(async () => (await noteText()) === "Finishing the word…"); + const chainStart = Date.now(); + while (Date.now() - chainStart < 450) { + await line.evaluate((element: HTMLTextAreaElement) => { + element.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "한" })); + element.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true, data: "" })); + }); + await page.waitForTimeout(40); + } + // already withdrawn when the chain ends: a wait re-armed per composition would still be open + assert.equal(await noteText(), "Finish the word, then send.", "the tap's deadline holds through a chain of compositions"); + await line.dispatchEvent("compositionend", { data: "한" }); + await page.waitForTimeout(100); + assert.equal(sent.length, 2, "a chain of compositions past the deadline sends nothing"); + assert.equal(await noteText(), "Finish the word, then send."); + // A drop and a quick reconnect while the word is still open withdraw the tap: the later + // commit sends nothing and the note says why. + await line.fill("drop 한"); + await line.dispatchEvent("compositionstart", { data: "" }); + await sendButton.click(); + await until(async () => (await noteText()) === "Finishing the word…"); + refuseConnections = true; + await currentSocket!.close(); + await until(() => sendButton.isDisabled()); + // the withdrawal runs in an effect after the disabled button paints + await until(async () => (await noteText()) === "Not sent: the terminal is disconnected."); + refuseConnections = false; + await until(async () => !(await sendButton.isDisabled())); + await line.dispatchEvent("compositionend", { data: "한" }); await page.waitForTimeout(100); - assert.equal(sent.length, 0, "unfinished composition must not submit via button"); + assert.equal(sent.length, 2, "a commit after a drop and reconnect does not send the withdrawn tap"); + assert.equal(await noteText(), "Not sent: the terminal is disconnected."); + // The terminal drops while the word is still being finished: the commit says so instead of + // leaving "Finishing the word…" up for good. + await line.fill("offline 한"); + await line.dispatchEvent("compositionstart", { data: "" }); + await sendButton.click(); + await until(async () => (await note.textContent()) === "Finishing the word…"); + refuseConnections = true; + await currentSocket!.close(); + await until(() => sendButton.isDisabled()); + await line.dispatchEvent("compositionend", { data: "한" }); + await until(async () => (await noteText()) !== "Finishing the word…"); + assert.equal(await note.textContent(), "Not sent: the terminal is disconnected."); + assert.equal(sent.length, 2); + refuseConnections = false; + await until(async () => !(await sendButton.isDisabled())); + // No commit within the wait: the tap is withdrawn with a note, and a later commit sends nothing. + await line.fill("draft 한글 😀"); + await line.dispatchEvent("compositionstart", { data: "" }); + await sendButton.click(); + await until(async () => (await note.textContent()) === "Finish the word, then send."); + await line.dispatchEvent("compositionend", { data: "😀" }); + // past a deferred send's tick and a whole commit window + await page.waitForTimeout(400); + assert.equal(sent.length, 2, "a commit after the withdrawn tap does not send"); + assert.equal(await note.textContent(), "Finish the word, then send."); + await line.dispatchEvent("compositionstart", { data: "" }); // Leaving during composition must not leave the composition guard stuck. await page.getByTitle("Chat transcript (⌘⇧J)", { exact: true }).click(); await page.getByTitle("Live terminal (⌘⇧J)", { exact: true }).click(); @@ -81,7 +348,7 @@ export async function checkTerminalInput(browser: Browser, origin: string, pane: acknowledge!(); await until(async () => !(await page.getByRole("button", { name: "Send to the terminal", exact: true }).isDisabled())); assert.equal(await line.inputValue(), "draft 한글 😀", "late ack cannot erase replacement text"); - assert.equal(sent.length, 1); + assert.equal(sent.length, 3); await page.keyboard.press("ControlOrMeta+Shift+Comma"); await openSettingsPage(page, "Shortcuts"); @@ -127,21 +394,102 @@ export async function checkTerminalInput(browser: Browser, origin: string, pane: await page.locator(".xterm-helper-textarea").waitFor(); await until(() => !!ready); const before = sent.length; + const heldNotice = page.getByText("Input held until the terminal is ready:", { exact: true }); + const leftOut = page.getByText("An Enter or line break was left out: Send never presses Enter.", { exact: true }); + // Enter on a terminal that is not ready, with nothing held, separates nothing: no notice. + // The "a" landing after it proves the Enter was handled first. + await page.locator(".xterm-helper-textarea").press("Enter"); await page.locator(".xterm-helper-textarea").press("a"); await until(async () => (await page.locator(".draft-text").textContent()) === "a"); + assert.equal(await leftOut.count(), 0); + // Backspace edits what is held; an Enter is never held, but it is told + await page.locator(".xterm-helper-textarea").press("b"); + await until(async () => (await page.locator(".draft-text").textContent()) === "ab"); + await page.locator(".xterm-helper-textarea").press("Backspace"); + await until(async () => (await page.locator(".draft-text").textContent()) === "a"); + assert.equal(await leftOut.count(), 0); + await page.locator(".xterm-helper-textarea").press("Enter"); + await leftOut.waitFor(); + await until(async () => (await page.locator(".draft-text").textContent()) === "a "); assert.equal(sent.length, before); - // Exactly the first held character must survive reload, not merely later edits. + // Exactly the held text must survive reload, not merely later edits, and so must the left-out notice. ready = undefined; await page.reload(); await page.locator(".draft-text").waitFor(); - assert.equal(await page.locator(".draft-text").textContent(), "a"); + assert.equal(await page.locator(".draft-text").textContent(), "a "); + await leftOut.waitFor(); + assert.equal(await heldNotice.count(), 1); await until(() => !!ready); ready!(); await page.waitForTimeout(150); assert.equal(sent.length, before, "ready does not replay the draft"); - await page.getByRole("button", { name: "Send", exact: true }).click(); + // Held input keeps later typing until Send or Discard, on a ready terminal too: a key sent beside + // it would reach the pty without the held start, and an Enter would run that partial line. The + // "b" landing after the Enter proves the Enter was handled first (the Enter folds into the held space). + await page.locator(".xterm-helper-textarea").press("Enter"); + await page.locator(".xterm-helper-textarea").press("b"); + await until(async () => (await page.locator(".draft-text").textContent()) === "a b"); + assert.equal(sent.length, before, "typing beside a held draft reaches nothing"); + // an interrupt is not typing: it still reaches the program while the draft waits + await page.locator(".xterm-helper-textarea").press("Escape"); await until(() => sent.length > before); - assert.equal(sent.at(-1).text, "a"); + assert.equal(sent.at(-1).text, "\x1b"); + assert.equal(await page.locator(".draft-text").textContent(), "a b"); + const heldNote = "Not sent: send or discard the held input first."; + const banner = page.locator(".terminal-banner", { hasText: heldNote }); + const dismissBanner = async () => { await banner.getByRole("button", { name: "Dismiss", exact: true }).click(); await banner.waitFor({ state: "hidden" }); }; + const afterEscape = sent.length; + // Ctrl+Enter under modifyOtherKeys (Claude Code's "send now") carries no line break once encoded: + // it must still be refused, not run the pty's partial line + toPage!({ type: "pty-data", pane_id: pane, data: "\x1b[>4;1m" }); + await page.waitForTimeout(100); + await page.locator(".xterm-helper-textarea").press("Control+Enter"); + await banner.waitFor(); + assert.equal(sent.length, afterEscape, "Ctrl+Enter beside a held draft reaches nothing"); + await dismissBanner(); + toPage!({ type: "pty-data", pane_id: pane, data: "\x1b[>4;0m" }); + // a paste beside a held draft is never sent ahead of the held start: one the draft cannot take is + // refused with the note, a bracketed one (the shell asks for it) holds its tab as a space + const pasteIntoTerminal = (text: string) => page.locator(".xterm-helper-textarea").evaluate((element, value) => { + const data = new DataTransfer(); + data.setData("text/plain", value); + element.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true })); + }, text); + const draftText = () => page.locator(".draft-text").textContent(); + await pasteIntoTerminal("a\tb"); + await until(async () => (await banner.isVisible()) || (await draftText()) === "a ba b"); + assert.equal(sent.length, afterEscape, "a paste with a tab beside a held draft reaches nothing"); + if (await banner.isVisible()) { + assert.equal(await draftText(), "a b"); + await dismissBanner(); + } + const beforeMultiLine = await draftText(); + await pasteIntoTerminal("x\ny"); + await page.waitForTimeout(100); + assert.equal(sent.length, afterEscape, "a multi-line paste beside a held draft reaches nothing"); + // never a silent drop: refused with the note, or taken into the draft (a draft that folds line breaks) + const multiLineNoted = await banner.isVisible(); + assert(multiLineNoted || (await draftText()) !== beforeMultiLine, "a multi-line paste is told or held, never dropped in silence"); + if (multiLineNoted) await dismissBanner(); + // the input line is refused beside a held draft too, with the held-input reason, not "disconnected" + await setMode("line"); + const lineNote = page.locator(".terminal-input-note"); + await line.fill(""); + await line.press("Enter"); + await until(async () => (await lineNote.textContent()) === heldNote); + await line.fill("ls"); + await line.press("Enter"); + await until(async () => (await lineNote.textContent()) === heldNote); + assert.equal(await line.inputValue(), "ls", "a refused line keeps its text"); + assert.equal(sent.length, afterEscape, "the input line beside a held draft reaches nothing"); + await line.fill(""); + await setMode("direct"); + // a draft that takes pastes holds more than "a b" by now: Send types what it holds + const heldText = await page.locator(".draft-text").textContent(); + await page.getByRole("button", { name: "Send", exact: true }).click(); + await until(() => sent.length > before + 1); + assert.equal(sent.at(-1).text, heldText); + assert.equal(sent.length, before + 2, "Send types the held text once, with no Enter"); // Real xterm handling: an IME's provisional syllable stays local until commit. const imeBefore = sent.length; const input = page.locator(".xterm-helper-textarea"); @@ -228,13 +576,78 @@ export async function checkTerminalInput(browser: Browser, origin: string, pane: assert.equal(sent.length, switchBefore, "old composition cannot cross a pane reset"); assert.equal(await page.locator(".draft-text").count(), 0, "old commit cannot enter the new pane's held draft"); // A late end after reset is stale, not a second attempt to send the abandoned input. - await page.locator(".xterm-helper-textarea").dispatchEvent("compositionend", { data: "한" }); + await page.locator(".pane-frame.is-current .xterm-helper-textarea").dispatchEvent("compositionend", { data: "한" }); await page.waitForTimeout(75); assert.equal(sent.length, switchBefore); console.log("PASS xterm composition: local preedit, delayed multi-commit burst, punctuation order, Korean final-consonant movement, pane-reset cancellation"); assert.deepEqual(errors, []); console.log("PASS terminal input: desktop mode, owner drafts, reload, IME button, late ack, shortcuts, readiness, explicit send"); } finally { await context.close(); } + await checkKeyBarWhileLineComposes(browser, origin, pane); +} + +/** A composition in the input line must not grey out the phone key bar: Esc and ^C go to the pane at once. */ +async function checkKeyBarWhileLineComposes(browser: Browser, origin: string, pane: string): Promise { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.setDefaultTimeout(10_000); + await context.addInitScript((pane) => { + localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", terminalInputMode: "line" })); + localStorage.setItem(`herdr-web-ui:view:${pane}`, "terminal"); + }, pane); + const frames: any[] = []; + await page.routeWebSocket(/\/ws(?:\?|$)/, (socket) => { + const server = socket.connectToServer(); + server.onMessage((raw) => socket.send(raw)); + socket.onMessage((raw) => { + const frame = JSON.parse(String(raw)); + // intercepted: the assertions are about what the browser sends, not the PTY + if (frame.type === "input" || frame.type === "keys" || frame.type === "submit") frames.push(frame); + else server.send(raw); + }); + }); + const until = async (check: () => boolean | Promise, label: string) => { + const deadline = Date.now() + 10_000; + while (!(await check())) { assert(Date.now() < deadline, `timed out: ${label}`); await new Promise((r) => setTimeout(r, 25)); } + }; + try { + await page.goto(`${origin}/?pane=${encodeURIComponent(pane)}`); + const line = page.locator(".terminal-input-text"); + const esc = page.locator('.key-bar [data-key="Escape"]'); + const interrupt = page.locator('.key-bar [data-key="ctrl-c"]'); + await line.waitFor(); + await until(async () => !(await esc.isDisabled()), "key bar ready"); + await line.fill("fix the"); + await line.dispatchEvent("compositionstart", { data: "" }); + await line.dispatchEvent("compositionupdate", { data: "the" }); + // let React commit any state the composition would set before reading the bar + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + assert.equal(await esc.isDisabled(), false, "Esc stays live while the input line composes"); + assert.equal(await interrupt.isDisabled(), false, "^C stays live while the input line composes"); + assert.equal(await page.locator('.key-bar [data-key="Control"]').isDisabled(), false, "Ctrl stays live while the input line composes"); + const before = frames.length; + await esc.tap(); + await until(() => frames.length > before, "Esc frame"); + await interrupt.tap(); + await until(() => frames.length > before + 1, "^C frame"); + assert.deepEqual(frames.slice(before).map((frame) => [frame.type, frame.text ?? frame.keys]), [["input", "\x1b"], ["input", "\x03"]]); + assert.equal(await line.inputValue(), "fix the", "the composing line keeps its text"); + // the switch to direct typing would unmount the line mid-composition: it waits + const direct = page.locator('.key-bar [data-key="direct"]'); + assert.equal(await direct.isDisabled(), true, "the direct-typing toggle waits for the line's composition"); + const sent = frames.length; + await direct.tap({ force: true }); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + assert.equal(await line.isVisible(), true, "the input line stays while it composes"); + assert.equal(await line.inputValue(), "fix the", "the composing line keeps its text after the direct tap"); + assert.equal(frames.length, sent, "nothing reaches the pane from the direct tap"); + await line.dispatchEvent("compositionend", { data: "the" }); + await until(async () => !(await direct.isDisabled()), "direct toggle live after the composition"); + assert.deepEqual(errors, []); + console.log("PASS key bar while the input line composes: Esc and ^C sent, direct toggle waits, line text kept"); + } finally { await context.close(); } } @@ -249,7 +662,7 @@ if (import.meta.main) { const root = mkdtempSync(join(tmpdir(), "herdr-input-qa-")); const owned: string[] = []; const server = createServer({ port: 0, stateDir: root, token: "" }); - const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); + const browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); try { const panes: string[] = []; for (let i = 0; i < 2; i++) { diff --git a/scripts/terminal-ipad-ime-regression.ts b/scripts/terminal-ipad-ime-regression.ts new file mode 100644 index 000000000..e3c2a226d --- /dev/null +++ b/scripts/terminal-ipad-ime-regression.ts @@ -0,0 +1,179 @@ +/** Replay the native iPadOS Safari + hardware keyboard trace (Korean 2-set), not a claim to automate the OS IME. */ +import assert from "node:assert/strict"; +import type { Browser } from "playwright-core"; + +/** + * iPadOS 27 Safari with a Magic Keyboard sends each jamo as a printable keydown (its own keyCode, + * not 229, isComposing false), then a keypress, then composes in the editor: a new syllable is an + * insertText, a changed one is deleteContentBackward followed by insertText of the whole result. + * There are no composition events. Recorded with scripts/terminal-ime-diagnostic.ts on 2026-10-13. + */ +export async function checkIpadIme(browser: Browser, origin: string, pane: string, otherPane: string): Promise { + // one context for both platforms: the page, not the context, carries the platform it reports + const context = await browser.newContext({ + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.2 Safari/605.1.15", + viewport: { width: 1100, height: 700 }, + }); + try { + // the platform iPadOS Safari reports by default (desktop site), and the one it reports otherwise + for (const platform of ["MacIntel", "iPad"]) { + const page = await context.newPage(); + await page.addInitScript(({ pane, platform }) => { + Object.defineProperty(navigator, "platform", { get: () => platform }); + localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", terminalInputMode: "direct" })); + localStorage.setItem(`herdr-web-ui:view:${pane}`, "terminal"); + }, { pane, platform }); + const sent: Array<{ pane_id: string; text: string }> = []; + let ready = false; + // output for the page as if the pane wrote it: input here never reaches the pane to echo + let toPage = (_data: string): void => {}; + await page.routeWebSocket(/\/ws(?:\?|$)/, (socket) => { + const server = socket.connectToServer(); + toPage = (data) => socket.send(JSON.stringify({ type: "pty-data", pane_id: pane, data })); + server.onMessage((raw) => { + const frame = JSON.parse(String(raw)); + if (frame.type === "input-ready") ready = frame.ready !== false; + socket.send(raw); + }); + socket.onMessage((raw) => { + const frame = JSON.parse(String(raw)); + if (frame.type === "input") sent.push(frame); + else server.send(raw); + }); + }); + const until = async (check: () => boolean | Promise) => { + const deadline = Date.now() + 10_000; + while (!(await check())) { assert(Date.now() < deadline, "iPad trace replay timed out"); await new Promise((resolve) => setTimeout(resolve, 25)); } + }; + const input = page.locator(".xterm-helper-textarea:visible"); + const reset = async () => { + ready = false; + await page.goto(`${origin}/?pane=${encodeURIComponent(pane)}`); + await until(() => ready); + await input.focus(); + await input.evaluate((box) => { (box as HTMLTextAreaElement).value = ""; }); + sent.length = 0; + }; + // One key of the recorded order: keydown (jamo, real keyCode), keypress, then the editor's + // edits. A cancelled keydown means a browser makes no edit, which is the failure this guards. + const KEYCODES: Record = { "ㅎ": 71, "ㅏ": 75, "ㄴ": 83, "ㄱ": 82, "ㅡ": 77, "ㄹ": 70, "ㅇ": 68, "ㅁ": 65 }; + const key = async (jamo: string, edits: Array<["delete"] | ["insert", string]>) => { + return input.evaluate(async (element, { jamo, keyCode, edits }) => { + const box = element as HTMLTextAreaElement; + const down = box.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: jamo, code: "", keyCode, isComposing: false })); + const press = down && box.dispatchEvent(new KeyboardEvent("keypress", { bubbles: true, cancelable: true, key: jamo, keyCode: jamo.charCodeAt(0), charCode: jamo.charCodeAt(0) })); + if (down && press) { + for (const edit of edits) { + const end = box.value.length; + if (edit[0] === "delete") { + box.setSelectionRange(end - 1, end); + box.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, composed: true, inputType: "deleteContentBackward", isComposing: false })); + box.setRangeText("", end - 1, end, "end"); + box.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true, inputType: "deleteContentBackward", isComposing: false })); + } else { + box.setSelectionRange(end, end); + box.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, composed: true, inputType: "insertText", data: edit[1], isComposing: false })); + box.setRangeText(edit[1], end, end, "end"); + box.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true, inputType: "insertText", data: edit[1], isComposing: false })); + } + } + } + box.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: jamo, keyCode, isComposing: false })); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + return down; + }, { jamo, keyCode: KEYCODES[jamo] ?? 65, edits }); + }; + const fresh = (jamo: string) => key(jamo, [["insert", jamo]]); + const replace = (jamo: string, result: string) => key(jamo, [["delete"], ["insert", result]]); + const check = async (expected: string, label: string) => { + await input.press("Control+g"); + await until(() => sent.at(-1)?.text === "\x07"); + assert.equal(sent.map((frame) => frame.text).join(""), expected + "\x07", `${label} (platform ${platform})`); + assert(sent.every((frame) => frame.pane_id === pane), "input stays with its pane"); + }; + try { + await reset(); + await input.press("a"); await input.press("b"); await input.press("c"); + assert.equal(await fresh("ㅎ"), true, `a Hangul keydown is left to the editor (platform ${platform})`); + await replace("ㅏ", "하"); await replace("ㄴ", "한"); + assert.equal(sent.map((frame) => frame.text).join(""), "abc", "provisional Hangul stays local, no jamo is sent"); + await fresh("ㄱ"); await replace("ㅡ", "그"); await replace("ㄹ", "글"); + await input.press("Space"); + await check("abc한글 ", "English then iPad Hangul commits each syllable once, in order"); + // the syllable being typed is drawn at the size its row gives the committed ones + // (terminalGlyphs enlarges a two-cell glyph that leaves its cells half empty) + toPage("\r\n한 "); + await page.waitForFunction(() => [...document.querySelectorAll(".xterm-rows span")].some((span) => span.textContent?.includes("한"))); + await fresh("ㄱ"); await replace("ㅡ", "그"); + const sizes = await page.evaluate(() => { + const row = [...document.querySelectorAll(".xterm-rows span")].find((span) => span.textContent?.includes("한"))!; + const view = document.querySelector(".composition-view")!; + return { row: getComputedStyle(row).fontSize, view: getComputedStyle(view).fontSize, pending: view.classList.contains("active") ? view.textContent : null }; + }); + assert.equal(sizes.pending, "그", "a syllable is pending"); + assert(Math.abs(Number.parseFloat(sizes.view) - Number.parseFloat(sizes.row)) < 0.01, + `the pending syllable is drawn at its row's size: ${sizes.view} against ${sizes.row} (platform ${platform})`); + + await reset(); + // 망 then ㅏ: the final ㅇ moves on and the editor puts back two syllables at once + await fresh("ㅁ"); await replace("ㅏ", "마"); await replace("ㅇ", "망"); await replace("ㅏ", "마아"); + await input.press("Space"); + await check("마아 ", "a two-syllable replacement commits both"); + + for (const [press, expected] of [["Enter", "\r"], ["Control+c", "\x03"], ["Tab", "\t"]]) { + await reset(); await fresh("ㅎ"); await replace("ㅏ", "하"); await replace("ㄴ", "한"); + await input.press(press!); + await check("한" + expected, `iPad commit precedes ${press}`); + } + + await reset(); await fresh("ㅎ"); await replace("ㅏ", "하"); await replace("ㄴ", "한"); + await input.evaluate((box) => (box as HTMLTextAreaElement).blur()); + await until(() => sent.length > 0); + await input.focus(); + await check("한", "blur commits the pending syllable once"); + + await reset(); await fresh("ㅎ"); await replace("ㅏ", "하"); + ready = false; + await page.locator(`.pane-select[title^="${otherPane} —"]`).first().evaluate((node: HTMLElement) => node.click()); + await until(async () => (await page.evaluate(() => JSON.parse(localStorage.getItem("herdr-web-ui:selection")!).pane_id)) === otherPane); + await until(() => ready); + await input.press("Control+g"); + await until(() => sent.at(-1)?.text === "\x07"); + assert.equal(sent.map((frame) => frame.text).join(""), "\x07", "pending iPad text cannot enter another pane"); + console.log(` ok navigator.platform ${platform}`); + } finally { + // left open until the context closes: closing a page whose WebSocket is routed took the + // whole headless browser down on macOS + await page.goto("about:blank").catch(() => {}); + } + } + } finally { await context.close(); } + console.log("PASS iPad hardware-keyboard replay: jamo keys composed in the editor, pending syllable at its row's size, commit keys, two-syllable replacement, blur and pane reset"); +} + +if (import.meta.main) { + await import("./test-herdr.ts"); + const { createServer } = await import("../server/index.ts"); + const { workspaceCreate, workspaceClose } = await import("../server/herdr/client.ts"); + const { chromium } = await import("playwright-core"); + const { mkdtempSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const root = mkdtempSync(join(tmpdir(), "herdr-ipad-replay-")); + const server = createServer({ port: 0, hostname: "127.0.0.1", stateDir: root, token: "" }); + const owned: string[] = []; + let browser: Browser | undefined; + try { + browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true }); + const panes: string[] = []; + for (let i = 0; i < 2; i++) { + const made = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-ipad-replay" }); + owned.push(made.workspace.workspace_id); panes.push(made.root_pane.pane_id); + } + await checkIpadIme(browser, `http://127.0.0.1:${server.port}`, panes[0]!, panes[1]!); + } finally { + await browser?.close(); server.stop(); + for (const workspace of owned) await workspaceClose(workspace); + rmSync(root, { recursive: true, force: true }); + } +} diff --git a/scripts/terminal-pinch-regression.ts b/scripts/terminal-pinch-regression.ts index 1a26ed7f7..fc7034b5d 100644 --- a/scripts/terminal-pinch-regression.ts +++ b/scripts/terminal-pinch-regression.ts @@ -7,6 +7,7 @@ import { chromium } from "playwright-core"; import { createServer } from "../server/index.ts"; import { workspaceCreate, workspaceClose } from "../server/herdr/client.ts"; import { UsageService } from "../server/usage.ts"; +import { chromePath } from "./chrome-path.ts"; async function until(check: () => boolean | Promise, label: string): Promise { const deadline = Date.now() + 10_000; @@ -23,13 +24,17 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI // Keep the size saved by the gesture when this same page reloads. if (!localStorage.getItem("herdr-web-ui:settings")) localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" })); localStorage.setItem(`herdr-web-ui:view:${paneId}`, "terminal"); - const touchWindow = window as Window & { wheels_: number; remaining_: number[] }; + const touchWindow = window as Window & { wheels_: number; wheelModes_: number[]; remaining_: number[] }; touchWindow.wheels_ = 0; + touchWindow.wheelModes_ = []; touchWindow.remaining_ = []; document.addEventListener("touchend", (event) => { touchWindow.remaining_ = Array.from(event.touches, (touch) => touch.identifier); }, { capture: true }); - document.addEventListener("wheel", () => touchWindow.wheels_++, { capture: true }); + document.addEventListener("wheel", (event) => { + touchWindow.wheels_++; + touchWindow.wheelModes_.push(event.deltaMode); + }, { capture: true }); }, { paneId }); try { const page = await context.newPage(); @@ -59,6 +64,9 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI transform: (document.querySelector(".pane-terminal .xterm") as HTMLElement).style.transform, computedTransform: getComputedStyle(document.querySelector(".pane-terminal .xterm")!).transform, wheels: (window as Window & { wheels_: number }).wheels_, + wheelModes: (window as Window & { wheelModes_: number[] }).wheelModes_.slice(), + cell: document.querySelector(".pane-terminal .xterm-screen")!.getBoundingClientRect().height + / document.querySelectorAll(".pane-terminal .xterm-rows > div").length, remaining: (window as Window & { remaining_: number[] }).remaining_, scale: visualViewport!.scale, focused: document.hasFocus(), @@ -83,18 +91,44 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI } }; const release = () => cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + // a wheel report per three rows of travel: 150 px is more than that even at the largest cell the 22 px font makes; + // the drag ends at cy, where a following pinch puts this finger, so that jump is no vertical travel const dragOne = async (): Promise => { - await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx, y: cy - 60, id: 1 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx, y: cy - 150, id: 1 }] }); for (let step = 1; step <= 5; step++) { - await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx, y: cy - 60 + 60 * step / 5, id: 1 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx, y: cy - 150 + 150 * step / 5, id: 1 }] }); } }; + /** one finger drags `distance` px down in `moves` touchmoves; resolves to the wheels it sent, each line mode (1) */ + const swipe = async (distance: number, moves: number): Promise => { + const before = await probe(); + await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx, y: cy - distance / 2, id: 1 }] }); + for (let step = 1; step <= moves; step++) { + await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx, y: cy - distance / 2 + distance * step / moves, id: 1 }] }); + } + await release(); + const after = await probe(); + assert(after.wheelModes.slice(before.wheelModes.length).every((mode) => mode === 1), + `a ${moves}-move swipe sends line-mode wheels only`); + return after.wheels - before.wheels; + }; + const baseline = await probe(); assert.equal(baseline.fontPx, 13, "baseline font is 13 px"); assert(!(await badge.isVisible()), "badge is hidden before pinching"); + // a quick swipe scrolls as far as a slow drag over the same travel: one wheel report per three rows + const fast = await swipe(300, 2); + const slow = await swipe(300, 30); + const expected = 300 / (3 * baseline.cell); + assert(fast >= Math.floor(expected) - 1 && fast <= Math.ceil(expected), `a 300 px swipe sends about ${expected.toFixed(1)} wheels (sent ${fast})`); + assert.equal(fast, slow, "a 2-move swipe and a 30-move drag over 300 px send the same wheel count"); + // a flick shorter than one step still scrolls once it reaches half a step when the finger lifts + const step = 3 * baseline.cell; + assert.equal(await swipe(Math.round(step * 0.7), 4), 1, `a flick of 0.7 step (${Math.round(step * 0.7)} px) sends one wheel`); + assert.equal(await swipe(Math.round(step * 0.3), 4), 0, `a flick of 0.3 step (${Math.round(step * 0.3)} px) sends none`); const r0 = resizes; - const w0 = baseline.wheels; + const w0 = (await probe()).wheels; await spreadTo(100, 154); await until(async () => (await probe()).fontPx === 20, "pinch out renders 20 px before release"); await until(async () => (await probe()).rows < baseline.rows, "pinch out refits fewer local rows before release"); @@ -170,8 +204,10 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI assert.equal((await probe()).fontPx, 20, "partial release keeps the live 20 px font"); assert.equal((await probe()).saved, 20, "partial release saves 20 px"); assert(!(await badge.isVisible()), "the first lifted finger hides the size badge"); + // three wheel steps of travel at the 20 px cell: a broken pinch-stroke guard would send wheels here + const leftover = 3 * 3 * (await probe()).cell; for (let step = 1; step <= 5; step++) { - await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx - 77, y: cy + 60 * step / 5, id: 1 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx - 77, y: cy + leftover * step / 5, id: 1 }] }); } assert.equal((await probe()).wheels, w1, "the finger remaining after a pinch does not scroll"); await release(); @@ -199,7 +235,7 @@ if (import.meta.main) { try { const made = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-terminal-pinch", focus: false }); workspace = made.workspace.workspace_id; - browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); + browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); await checkTerminalPinch(browser, `http://127.0.0.1:${server.port}`, made.root_pane.pane_id); } finally { await browser?.close(); server.stop(); diff --git a/scripts/terminal-safari-ime-evidence.ts b/scripts/terminal-safari-ime-evidence.ts index a687591df..7b6e3036f 100644 --- a/scripts/terminal-safari-ime-evidence.ts +++ b/scripts/terminal-safari-ime-evidence.ts @@ -8,6 +8,7 @@ import { chromium } from "playwright-core"; import { preview } from "vite"; import { createServer } from "../server/index.ts"; import { paneRead, paneSendText, workspaceClose, workspaceCreate } from "../server/herdr/client.ts"; +import { chromePath } from "./chrome-path.ts"; assert(process.env.HERDR_TEST_LIVE !== "1" && process.env.HERDR_TEST_MODE !== "unit"); process.env.HERDR_TEST_SESSION ||= "herdr-web-ui-test-ime-evidence"; @@ -27,7 +28,7 @@ const until = async (check: () => boolean | Promise, message: string) = const deadline = Date.now() + 15_000; while (!(await check())) { assert(Date.now() < deadline, message); await Bun.sleep(25); } }; -const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || "/opt/google/chrome/chrome", headless: true }); +const browser = await chromium.launch({ executablePath: chromePath(), headless: true }); try { for (const [label, dist] of [["before", before], ["after", after]]) { const root = mkdtempSync(join(tmpdir(), "herdr-ime-evidence-")); diff --git a/scripts/terminal-safari-ime-regression.ts b/scripts/terminal-safari-ime-regression.ts index 533a391aa..063a3289b 100644 --- a/scripts/terminal-safari-ime-regression.ts +++ b/scripts/terminal-safari-ime-regression.ts @@ -1,6 +1,7 @@ /** Replay the native Safari 26.6.2 trace from #432, not a claim to automate the OS IME. */ import assert from "node:assert/strict"; import type { Browser } from "playwright-core"; +import { chromePath } from "./chrome-path.ts"; /** Replay Safari replacement edits and assert emitted bytes, commit order and pane isolation. */ export async function checkSafariIme(browser: Browser, origin: string, pane: string, otherPane: string): Promise { @@ -33,7 +34,7 @@ export async function checkSafariIme(browser: Browser, origin: string, pane: str const deadline = Date.now() + 10_000; while (!(await check())) { assert(Date.now() < deadline, "Safari trace replay timed out"); await new Promise((resolve) => setTimeout(resolve, 25)); } }; - const input = page.locator(".xterm-helper-textarea"); + const input = page.locator(".pane-frame.is-current .xterm-helper-textarea"); const reset = async () => { ready = false; await page.goto(`${origin}/?pane=${encodeURIComponent(pane)}`); @@ -191,7 +192,7 @@ if (import.meta.main) { const owned: string[] = []; let browser: Browser | undefined; try { - browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true }); + browser = await chromium.launch({ executablePath: chromePath(), headless: true }); const panes: string[] = []; for (let i = 0; i < 2; i++) { const made = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-safari-replay" }); diff --git a/scripts/terminal-viewport-regression.ts b/scripts/terminal-viewport-regression.ts index 979f8fe04..97c4cd9b5 100644 --- a/scripts/terminal-viewport-regression.ts +++ b/scripts/terminal-viewport-regression.ts @@ -1,4 +1,4 @@ -/** Search decoration and shared-history scrollbar against panes owned by this script. */ +/** Search decoration and shared-history paging, with no history scrollbar, against panes owned by this script. */ import "./test-herdr.ts"; import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; @@ -10,9 +10,10 @@ import type { PaneFindResponse } from "../shared/protocol.ts"; import { createServer } from "../server/index.ts"; import { UsageService } from "../server/usage.ts"; import { herdrRpc, paneScrollInfo, workspaceCreate, workspaceClose } from "../server/herdr/client.ts"; +import { chromePath } from "./chrome-path.ts"; const state = mkdtempSync(join(tmpdir(), "herdr-viewport-browser-")); const server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: state, usage: new UsageService(undefined, []) }); -const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); +const browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); let workspace: string | undefined; async function until(check: () => Promise, message: string): Promise { const deadline = Date.now() + 10000; @@ -51,16 +52,36 @@ try { } }; page.on("pageerror", (error) => errors.push(error.message)); + // Native history moves the way a user moves it: the paging shortcut, one acknowledged page at a time. + const pageToEdge = async (direction: -1 | 1) => { + const deadline = Date.now() + 10000; + await page.locator(".xterm-helper-textarea").evaluate((node: HTMLTextAreaElement) => node.focus()); + for (;;) { + const info = (await paneScrollInfo(paneId))!; + if (info.offset_from_bottom === (direction > 0 ? info.max_offset_from_bottom : 0)) return; + assert.ok(Date.now() < deadline, `paging reaches the ${direction > 0 ? "top" : "bottom"} of native history`); + const acknowledged = page.waitForResponse((response) => response.url().endsWith("/api/pane/scroll") && response.request().method() === "POST"); + await page.keyboard.press(direction > 0 ? "ControlOrMeta+Shift+PageUp" : "ControlOrMeta+Shift+PageDown"); + await acknowledged.catch(async (error) => { throw new Error(`no page POST toward ${direction > 0 ? "top" : "bottom"} from ${JSON.stringify(info)}, focus ${await page.evaluate(() => document.activeElement?.className)}: ${error}`); }); + } + }; + const firstMetrics = page.waitForResponse((response) => response.url().includes("/api/pane/scroll?") && response.request().method() === "GET"); await page.goto(`http://127.0.0.1:${server.port}/?pane=${encodeURIComponent(paneId)}`); - const scrollbar = page.getByRole("scrollbar", { name: "Terminal scrollback", exact: true }); - await scrollbar.waitFor(); - const max = Number(await scrollbar.getAttribute("aria-valuemax")); - assert.ok(max > 100, "the scrollbar reads herdr's full history"); + await firstMetrics; + assert.ok((await paneScrollInfo(paneId))!.max_offset_from_bottom > 100, "herdr holds the full history"); + const widths = await page.evaluate(() => ({ grid: document.querySelector(".pane-terminal")!.getBoundingClientRect().width, surface: document.querySelector(".terminal-surface")!.getBoundingClientRect().width })); + assert.equal(widths.grid, widths.surface, "the grid takes the full width, with no column reserved beside it"); // Paging moves native history without reaching the program’s input. await page.locator(".xterm-helper-textarea").evaluate((node: HTMLTextAreaElement) => node.focus()); const inputsBeforePaging = terminalInput.length; const pageRows = (await paneScrollInfo(paneId))!.viewport_rows - 1; - await page.keyboard.press("ControlOrMeta+Shift+PageUp"); + // a page asked for before the client's first metrics read only asks for that read: press until one is sent + for (let attempt = 0; ; attempt++) { + assert.ok(attempt < 10, "the client pages once it has read the pane's metrics"); + const sent = page.waitForRequest((request) => request.url().endsWith("/api/pane/scroll") && request.method() === "POST", { timeout: 3000 }).then(() => true, () => false); + await page.keyboard.press("ControlOrMeta+Shift+PageUp"); + if (await sent) break; + } await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom === pageRows, "the global shortcut pages by native rows minus one"); await page.keyboard.press("ControlOrMeta+Shift+PageDown"); await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom === 0, "the opposite shortcut returns to the native bottom"); @@ -86,61 +107,96 @@ try { await page.unroute("**/api/pane/scroll", holdPaging); await page.keyboard.press("ControlOrMeta+Shift+PageDown"); await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom === pageRows * 2, "paging after the ACK starts at its acknowledged offset"); - await scrollbar.focus(); - await page.keyboard.press("End"); - await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom === 0, "reset native history after repeat paging"); + await pageToEdge(-1); - for (const rows of [3, 0, 1]) { + { await page.keyboard.press("ControlOrMeta+Shift+Comma"); await openSettingsPage(page, "Shortcuts"); - const overlap = page.getByRole("group", { name: "Page overlap", exact: true }).locator("output"); - for (let clicks = 0; Number(await overlap.textContent()) !== rows; clicks++) { - assert.ok(clicks <= 100, `page overlap reaches ${rows}, showing ${await overlap.textContent()}`); - await page.getByRole("button", { name: Number(await overlap.textContent()) < rows ? "Increase page overlap" : "Decrease page overlap", exact: true }).click(); - } await page.keyboard.press("ControlOrMeta+Shift+PageUp"); assert.equal((await paneScrollInfo(paneId))!.offset_from_bottom, 0, "Settings blocks the pane's paging control"); await page.keyboard.press("Escape"); await page.locator(".xterm-helper-textarea").evaluate((node: HTMLTextAreaElement) => node.focus()); - const expected = Math.max(1, (await paneScrollInfo(paneId))!.viewport_rows - rows); + // one line stays between pages + const expected = Math.max(1, (await paneScrollInfo(paneId))!.viewport_rows - 1); await page.keyboard.press("ControlOrMeta+Shift+PageUp"); - await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom === expected, `native paging respects ${rows} overlap rows`); + await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom === expected, "native paging keeps one overlap row"); await page.keyboard.press("ControlOrMeta+Shift+PageDown"); await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom === 0, "native paging down reaches the bottom"); - await scrollbar.focus(); - await page.keyboard.press("PageUp"); - await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom === expected, "focused scrollbar paging uses the same overlap"); - await page.keyboard.press("PageDown"); - await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom === 0, "focused scrollbar paging down reaches the bottom"); } - // An app-owned PageDown must not also run the scrollbar’s local handler. + // A rebound global PageDown pages up, once; then the defaults come back for the rest of the script. await page.keyboard.press("ControlOrMeta+Shift+Comma"); await openSettingsPage(page, "Shortcuts"); await page.getByRole("combobox", { name: "Scroll pane down", exact: true }).selectOption("off"); await page.getByRole("combobox", { name: "Scroll pane up", exact: true }).selectOption("PageDown"); await page.keyboard.press("Escape"); - await scrollbar.focus(); + await page.locator(".xterm-helper-textarea").evaluate((node: HTMLTextAreaElement) => node.focus()); await page.keyboard.press("ControlOrMeta+Shift+PageDown"); await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom === pageRows, "a rebound global PageDown pages up only once"); - await page.keyboard.press("End"); - await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom === 0, "reset history after rebound paging"); + await page.keyboard.press("ControlOrMeta+Shift+Comma"); + await openSettingsPage(page, "Shortcuts"); + await page.getByRole("combobox", { name: "Scroll pane up", exact: true }).selectOption("PageUp"); + await page.getByRole("combobox", { name: "Scroll pane down", exact: true }).selectOption("PageDown"); + await page.keyboard.press("Escape"); + await pageToEdge(-1); assert.equal(terminalInput.length, inputsBeforePaging, "paging and settings never send input to the terminal program"); - await scrollbar.focus(); - await page.keyboard.press("Home"); - await page.waitForFunction(() => document.querySelector('[role="scrollbar"]')?.getAttribute("aria-valuenow") === "0"); - await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom > 100, "Home moves native history to the top"); - await page.keyboard.press("End"); - await page.waitForFunction(() => { const bar = document.querySelector('[role="scrollbar"]'); return bar?.getAttribute("aria-valuenow") === bar?.getAttribute("aria-valuemax"); }); - await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom === 0, "End reaches native bottom"); - const box = (await scrollbar.boundingBox())!; - const thumb = (await scrollbar.locator(".terminal-history-thumb").boundingBox())!; - await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2); - await page.mouse.down(); - await page.mouse.move(box.x + box.width / 2, box.y + thumb.height / 2, { steps: 8 }); - await page.mouse.up(); - await page.waitForFunction(() => Number(document.querySelector('[role="scrollbar"]')?.getAttribute("aria-valuenow")) < 3); - await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom > 100, "thumb drag moves the actual shared pane"); + await pageToEdge(1); + assert.ok((await paneScrollInfo(paneId))!.offset_from_bottom > 100, "paging reaches the top of native history"); + await pageToEdge(-1); + + // The metrics poll: a quiet pane at the bottom backs off, output brings it back, and a view scrolled + // up into history keeps reading every second, since growing history there may send no output. + const metricsReads: number[] = []; + const countRead = (request: import("playwright-core").Request) => { + if (request.method() === "GET" && request.url().includes("/api/pane/scroll?")) metricsReads.push(Date.now()); + }; + page.on("request", countRead); + try { + await until(async () => metricsReads.length > 0, "the poll reads metrics after paging to the bottom"); + const quietFrom = metricsReads.at(-1)!; + // fixed wait: proving the quiet pane's poll stays sparse over a window (a 1 s poll makes 8 reads) + await Bun.sleep(8000); + const quietReads = metricsReads.filter((at) => at > quietFrom).length; + assert.ok(quietReads <= 4, `a quiet pane backs its metrics poll off, read ${quietReads} times in 8 s`); + // A failed read on the backed-off poll leaves page keys without metrics; the retry must not wait out the idle backoff. + let failedAt = 0; + const failOnce = async (route: import("playwright-core").Route) => { + if (failedAt || route.request().method() !== "GET") return route.continue(); + failedAt = Date.now(); + await route.fulfill({ status: 502, contentType: "application/json", body: JSON.stringify({ error: "busy" }) }); + }; + await page.route("**/api/pane/scroll?**", failOnce); + try { + const failDeadline = Date.now() + 20000; + while (!failedAt) { assert.ok(Date.now() < failDeadline, "the backed-off poll reads again within its 15 s cap"); await Bun.sleep(25); } + // the failed request's own read event may land just after failedAt, so the retry starts 50 ms later + const afterFailure = () => metricsReads.find((at) => at > failedAt + 50); + await until(async () => afterFailure() !== undefined || Date.now() - failedAt > 3000, "a read follows the failed one"); + const retry = afterFailure(); + assert.ok(retry !== undefined && retry - failedAt < 1500, `a failed metrics read retries within about 1 s, took ${retry === undefined ? "over 3000" : retry - failedAt} ms`); + } finally { await page.unroute("**/api/pane/scroll?**", failOnce); } + const beforeOutput = metricsReads.length; + const outputAt = Date.now(); + await herdrRpc("pane.send_input", { pane_id: paneId, text: "poll_wake", keys: ["enter"] }); + await until(async () => metricsReads.length > beforeOutput, "output wakes the backed-off poll"); + assert.ok(metricsReads[beforeOutput]! - outputAt < 1500, `output brings the next read back within about 1 s, took ${metricsReads[beforeOutput]! - outputAt} ms`); + + await pageToEdge(1); + await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom > 100, "paging scrolls up before the held-view poll check"); + const upFrom = Date.now(); + // fixed wait: proving the scrolled-up poll keeps its 1 s cadence over a window the idle backoff would stretch + await Bun.sleep(6000); + const upReads = metricsReads.filter((at) => at > upFrom).length; + assert.ok(upReads >= 4, `a view scrolled up keeps reading metrics about every second, read ${upReads} times in 6 s`); + const heldMax = (await paneScrollInfo(paneId))!.max_offset_from_bottom; + const grown = page.waitForResponse(async (response) => response.url().includes("/api/pane/scroll?") && response.request().method() === "GET" + && (((await response.json()) as { scroll: { max_offset_from_bottom: number } | null }).scroll?.max_offset_from_bottom ?? 0) > heldMax, { timeout: 3000 }); + const grownAt = Date.now(); + for (let line = 0; line < 5; line++) await herdrRpc("pane.send_input", { pane_id: paneId, text: `held_view_${line}`, keys: ["enter"] }); + await grown; + assert.ok(Date.now() - grownAt < 3000, "history grown under a scrolled-up view reaches the client's metrics within about 3 s"); + } finally { page.off("request", countRead); } + await pageToEdge(-1); const beforeFindRows = (await paneScrollInfo(paneId))!.viewport_rows; await page.keyboard.press("ControlOrMeta+Shift+f"); @@ -163,10 +219,8 @@ try { } const count = await find.locator(".find-bar-count").textContent(); const readonlySearch = page.waitForRequest((request) => request.url().endsWith("/api/pane/find") && request.postDataJSON()?.jump === false); - await scrollbar.focus(); - await page.keyboard.press("End"); + await pageToEdge(-1); await readonlySearch; - await page.waitForFunction(() => { const bar = document.querySelector('[role="scrollbar"]'); return bar?.getAttribute("aria-valuenow") === bar?.getAttribute("aria-valuemax"); }); assert.equal(await find.locator(".find-bar-count").textContent(), count, "scroll refresh does not advance the search ordinal"); assert.equal((await paneScrollInfo(paneId))!.offset_from_bottom, 0, "background search refresh never moves the view"); await input.fill("界éZ"); @@ -176,7 +230,7 @@ try { const screen = (await page.locator(".xterm-screen").boundingBox())!; const wide = await page.evaluate(() => { const row = document.querySelector(".xterm-rows > div"); return row?.getBoundingClientRect().height ?? 0; }); assert.ok(hit.width > 20 && hit.height > 0 && wide > 0 && hit.x >= screen.x, "wide and combining text has visible cell-aligned emphasis"); - // An older scrollbar write must finish before a newer explicit search moves the pane. + // An older page write must finish before a newer explicit search moves the pane. // The fetch observer records dispatch synchronously; no timing sleep is needed to prove // Find is waiting while its UI already says Searching… and the POST is held below. await page.evaluate((owner) => { @@ -207,14 +261,14 @@ try { }; await page.route("**/api/pane/scroll", holdScroll); try { - await scrollbar.focus(); - await page.keyboard.press("Home"); + await page.locator(".xterm-helper-textarea").evaluate((node: HTMLTextAreaElement) => node.focus()); + await page.keyboard.press("ControlOrMeta+Shift+PageUp"); await Promise.race([intercepted.promise, new Promise((_resolve, reject) => { const signal = AbortSignal.timeout(10000); signal.addEventListener("abort", () => reject(new Error("scroll POST was not intercepted")), { once: true }); })]); // A second queued target is obsolete once the user asks Find to choose the viewport. - await page.keyboard.press("PageDown"); + await page.keyboard.press("ControlOrMeta+Shift+PageDown"); await input.fill("history 090: record"); const explicitResponse = page.waitForResponse((response) => response.url().endsWith("/api/pane/find") && response.request().postDataJSON()?.query === "history 090: record" && response.request().postDataJSON()?.jump !== false); @@ -225,7 +279,7 @@ try { await input.press("Enter"); await page.waitForFunction(() => document.querySelector(".find-bar input")?.readOnly === true); assert.equal(await page.evaluate(() => (window as any).__viewportOrderProbe.calls.filter((call: { path: string }) => call.path.endsWith("/pane/find")).length), 0, - "explicit native search must wait for the previous scrollbar request"); + "explicit native search must wait for the previous page request"); await page.evaluate(() => { (window as any).__viewportOrderProbe.released = true; }); releaseScroll.resolve(); const response = await explicitResponse; @@ -276,7 +330,7 @@ try { assert.deepEqual(errors, []); if (process.env.UI_EVIDENCE_DIR) await page.screenshot({ path: join(process.env.UI_EVIDENCE_DIR, "terminal-viewport.png") }); await context.close(); - console.log("PASS native history scrollbar, search highlights, wide cells, non-jumping refresh and old bridge compatibility"); + console.log("PASS no history scrollbar, full-width grid, native paging, search highlights, wide cells, non-jumping refresh and old bridge compatibility"); } finally { await browser.close(); server.stop(); if (workspace) await workspaceClose(workspace).catch(() => {}); diff --git a/scripts/test-herdr.test.ts b/scripts/test-herdr.test.ts new file mode 100644 index 000000000..17d3af7e1 --- /dev/null +++ b/scripts/test-herdr.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "bun:test"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { TEST_SESSION, testSocketPath } from "./test-herdr.ts"; + +const socket = (root: string): string => join(root, "herdr", "sessions", TEST_SESSION, "herdr.sock"); + +describe("testSocketPath", () => { + it("follows XDG_CONFIG_HOME first, on Windows too", () => { + expect(testSocketPath({ XDG_CONFIG_HOME: "/x", APPDATA: "/a" }, "win32")).toBe(socket("/x")); + expect(testSocketPath({ XDG_CONFIG_HOME: "/x" }, "linux")).toBe(socket("/x")); + }); + + it("uses APPDATA on Windows without it, and ~/.config elsewhere", () => { + expect(testSocketPath({ APPDATA: "/a" }, "win32")).toBe(socket("/a")); + expect(testSocketPath({ XDG_CONFIG_HOME: "", APPDATA: "" }, "win32")).toBe(socket(join(homedir(), "AppData", "Roaming"))); + expect(testSocketPath({ APPDATA: "/a" }, "darwin")).toBe(socket(join(homedir(), ".config"))); + }); +}); diff --git a/scripts/test-herdr.ts b/scripts/test-herdr.ts index 4308be834..dfa56707b 100644 --- a/scripts/test-herdr.ts +++ b/scripts/test-herdr.ts @@ -21,10 +21,15 @@ process.env["TMPDIR"] = realpathSync(tmpdir()); export const TEST_SESSION = process.env["HERDR_TEST_SESSION"] || "herdr-web-ui-test"; -/** Where herdr puts a named session's socket (it follows XDG_CONFIG_HOME, as remote-entry.ts does). */ -export function testSocketPath(): string { - const config = join(process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config"), "herdr"); - return join(config, "sessions", TEST_SESSION, "herdr.sock"); +/** + * Where herdr puts a named session's socket: under XDG_CONFIG_HOME when it is set, on Windows too + * (live-verified, herdr 0.9.3; `bun run check` isolates herdr through it), else under APPDATA on + * Windows and ~/.config elsewhere. + */ +export function testSocketPath(env: Record = process.env, platform: NodeJS.Platform = process.platform): string { + const root = env["XDG_CONFIG_HOME"] + || (platform === "win32" ? env["APPDATA"] || join(homedir(), "AppData", "Roaming") : join(homedir(), ".config")); + return join(root, "herdr", "sessions", TEST_SESSION, "herdr.sock"); } function herdrBinary(): string | null { diff --git a/scripts/touch-long-press-demo-regression.ts b/scripts/touch-long-press-demo-regression.ts index 952c24648..87e50f8ad 100644 --- a/scripts/touch-long-press-demo-regression.ts +++ b/scripts/touch-long-press-demo-regression.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { chromium, type Page } from "playwright-core"; import panes from "../site/demo/fixtures/panes.json"; import { buildDemoApp } from "./demo-build.ts"; +import { chromePath } from "./chrome-path.ts"; // A long press on a pane on a touch screen is the phone's own (select text, copy): it does not // open the pane menu. A right-click still does. On the unmodified app over the demo's fixture @@ -43,7 +44,7 @@ try { return (await body.exists()) ? new Response(body) : new Response("not found", { status: 404 }); } }); try { - const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); + const browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); try { const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: "en-US" }); await context.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", defaultView: "chat" }))); diff --git a/scripts/ui-contexts-checks.ts b/scripts/ui-contexts-checks.ts new file mode 100644 index 000000000..2c6102c04 --- /dev/null +++ b/scripts/ui-contexts-checks.ts @@ -0,0 +1,129 @@ +import "./test-herdr.ts"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium, type Browser } from "playwright-core"; +import { createServer } from "../server/index.ts"; +import { herdrRpc, workspaceCreate, workspaceClose } from "../server/herdr/client.ts"; +import { UsageService } from "../server/usage.ts"; +import { checkPushSettings } from "./push-settings-regression.ts"; +import { checkWakeLock } from "./wake-lock-regression.ts"; +import { checkSecretInput } from "./secret-input-regression.ts"; +import { checkTerminalCopy } from "./terminal-copy-regression.ts"; +import { checkPaneFind } from "./pane-find-regression.ts"; +import { checkUsageMeters } from "./usage-regression.ts"; +import { checkNotificationStartup } from "./notification-startup-regression.ts"; +import { checkNotificationView } from "./notification-view-regression.ts"; +import { checkMobileViewport } from "./mobile-viewport-regression.ts"; +import { checkTerminalPinch } from "./terminal-pinch-regression.ts"; +import { checkMobileTabs } from "./mobile-tabs-regression.ts"; +import { checkTabMenu } from "./tab-menu-regression.ts"; +import { checkDefaultView } from "./default-view-regression.ts"; +import { checkComposerReconnect } from "./composer-reconnect-regression.ts"; +import { checkDroplet } from "./droplet-regression.ts"; +import { checkSplitView } from "./split-view-regression.ts"; +import { checkTakeOver } from "./take-over-regression.ts"; +import { checkAlertSound } from "./alert-sound-regression.ts"; +import { checkBackgroundTabKeepsTerminalSize, checkChatKeepsTerminalSize, checkFocusedWindowReclaimsTerminalSize, checkPaneSwitchKeepsTerminalSize } from "./chat-size-regression.ts"; +import { checkUpdateNotice } from "./update-notice-regression.ts"; +import { checkTelemetrySettings } from "./telemetry-regression.ts"; +import { chromePath } from "./chrome-path.ts"; + +async function timed(name: string, check: () => Promise): Promise { + const started = performance.now(); + let checkFailed = false; + try { + await check(); + } catch (error) { + checkFailed = true; + throw error; + } finally { + try { + console.log(`TIMING ${name} seconds=${((performance.now() - started) / 1000).toFixed(3)}`); + } catch (error) { + if (!checkFailed) throw error; + } + } +} + +function isWorkspaceMissing(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "workspace_not_found"; +} + +export async function runUiContextChecks(): Promise { + const tempBase = existsSync("/tmp") ? "/tmp" : tmpdir(); + const root = realpathSync(mkdtempSync(join(tempBase, "hwc-"))); + const workspaces: string[] = []; + const cleanupErrors: unknown[] = []; + let server: ReturnType | undefined; + let browser: Browser | undefined; + let failed = false; + let failure: unknown; + + try { + const cwdA = join(root, "a"); + const cwdB = join(root, "b"); + mkdirSync(cwdA); + mkdirSync(cwdB); + const createdA = await workspaceCreate({ cwd: cwdA, label: "herdr-web-ui-contexts-a" }); + workspaces.push(createdA.workspace.workspace_id); + const createdB = await workspaceCreate({ cwd: cwdB, label: "herdr-web-ui-contexts-b" }); + workspaces.push(createdB.workspace.workspace_id); + const paneA = createdA.root_pane.pane_id; + const paneB = createdB.root_pane.pane_id; + await herdrRpc("pane.report_agent", { pane_id: paneB, source: "manual", agent: "codex", state: "idle" }); + + server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: root, usage: new UsageService(undefined, []) }); + const origin = `http://127.0.0.1:${server.port}`; + browser = await chromium.launch({ + executablePath: chromePath(), + headless: true, + args: ["--no-sandbox", "--accept-lang=en-US"], + }); + + await timed("checkPushSettings", () => checkPushSettings(browser!, origin)); + await timed("checkWakeLock", () => checkWakeLock(browser!, origin, paneA)); + await timed("checkSecretInput", () => checkSecretInput(browser!, origin)); + await timed("checkTerminalCopy", () => checkTerminalCopy(browser!, origin)); + await timed("checkPaneFind", () => checkPaneFind(browser!, origin)); + await timed("checkUsageMeters", () => checkUsageMeters(browser!, origin)); + await timed("checkNotificationStartup", () => checkNotificationStartup(browser!, origin, paneA, paneB)); + await timed("checkNotificationView", () => checkNotificationView(browser!, origin)); + await timed("checkMobileViewport", () => checkMobileViewport(browser!, origin, paneB)); + await timed("checkTerminalPinch", () => checkTerminalPinch(browser!, origin, paneB)); + await timed("checkMobileTabs", () => checkMobileTabs(browser!, origin)); + await timed("checkTabMenu", () => checkTabMenu(browser!, origin)); + await timed("checkDefaultView", () => checkDefaultView(browser!, origin)); + await timed("checkComposerReconnect", () => checkComposerReconnect(browser!, origin, paneB)); + await timed("checkDroplet", () => checkDroplet(browser!, origin)); + await timed("checkSplitView", () => checkSplitView(browser!, origin)); + await timed("checkTakeOver", () => checkTakeOver(browser!, origin)); + await timed("checkAlertSound", () => checkAlertSound(browser!, origin)); + await timed("checkChatKeepsTerminalSize", () => checkChatKeepsTerminalSize(browser!, origin)); + await timed("checkPaneSwitchKeepsTerminalSize", () => checkPaneSwitchKeepsTerminalSize(browser!, origin)); + await timed("checkBackgroundTabKeepsTerminalSize", () => checkBackgroundTabKeepsTerminalSize(browser!, origin)); + await timed("checkFocusedWindowReclaimsTerminalSize", () => checkFocusedWindowReclaimsTerminalSize(browser!, origin)); + await timed("checkUpdateNotice", () => checkUpdateNotice(browser!, origin)); + await timed("checkTelemetrySettings", () => checkTelemetrySettings(browser!)); + } catch (error) { + failed = true; + failure = error; + } + + if (browser) { + try { await browser.close(); } catch (error) { cleanupErrors.push(error); } + } + if (server) { + try { server.stop(); } catch (error) { cleanupErrors.push(error); } + } + for (const workspace of workspaces) { + try { await workspaceClose(workspace); } catch (error) { if (!isWorkspaceMissing(error)) cleanupErrors.push(error); } + } + try { rmSync(root, { recursive: true, force: true }); } catch (error) { cleanupErrors.push(error); } + + if (failed) { + if (cleanupErrors.length) console.error(new AggregateError(cleanupErrors, "UI context fixture cleanup failed after the check failed")); + throw failure; + } + if (cleanupErrors.length) throw new AggregateError(cleanupErrors, "UI context fixture cleanup failed"); +} diff --git a/scripts/ui-contexts-regression.ts b/scripts/ui-contexts-regression.ts new file mode 100644 index 000000000..e91acc3a5 --- /dev/null +++ b/scripts/ui-contexts-regression.ts @@ -0,0 +1,69 @@ +import { join } from "node:path"; +import { isolate } from "./check.ts"; + +if (process.env.HERDR_TEST_LIVE === "1") throw new Error("Cannot run live UI context checks with HERDR_TEST_LIVE=1"); +if (process.env.HERDR_TEST_MODE === "unit") throw new Error("HERDR_TEST_MODE=unit cannot run live UI context checks"); + +const herdr = process.env.HERDR_WEB_HERDR_BIN || Bun.which("herdr"); +if (!herdr) throw new Error("UI context checks require HERDR_WEB_HERDR_BIN or a herdr binary on PATH"); + +// CHECK_DIR belongs to the caller: never reuse its config, socket or state files. +const isolation = isolate({ ...process.env, CHECK_DIR: undefined }); +for (const name of Object.keys(process.env)) { + if (!(name in isolation.env)) delete process.env[name]; +} +Object.assign(process.env, isolation.env); +const configRoot = isolation.env.XDG_CONFIG_HOME!; +const session = isolation.env.HERDR_TEST_SESSION!; +const socket = join(isolation.sessions, session, "herdr.sock"); +// Pre-point lazy imports at our socket, even before test-herdr's bootstrap has completed. +process.env.HERDR_SOCKET = socket; +process.env.HERDR_WEB_HERDR_BIN = herdr; + +let failed = false; +let failure: unknown; +try { + const { runUiContextChecks } = await import("./ui-contexts-checks.ts"); + await runUiContextChecks(); +} catch (error) { + failed = true; + failure = error; +} + +const teardownErrors: unknown[] = []; +const stopEnv = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("HERDR_"))) as Record; +stopEnv.XDG_CONFIG_HOME = configRoot; +let stopped = false; +try { + const result = Bun.spawnSync([herdr, "--session", session, "server", "stop"], { + env: stopEnv, timeout: 10_000, stdout: "inherit", stderr: "inherit", + }); + stopped = result.exitCode === 0; + if (stopped) { + const { ping } = await import("../server/herdr/client.ts"); + const deadline = Date.now() + 10_000; + while (await ping(socket).then(() => true, () => false)) { + if (Date.now() >= deadline) { + stopped = false; + break; + } + await Bun.sleep(50); + } + } + if (!stopped) teardownErrors.push(new Error(`Owned herdr session ${session} failed to stop (exit ${result.exitCode}); config retained at ${configRoot}`)); +} catch (error) { + teardownErrors.push(new Error(`Owned herdr stop failed; config retained at ${configRoot}`, { cause: error })); +} +if (stopped) { + try { + isolation.remove(); + } catch (error) { + teardownErrors.push(error); + } +} + +if (failed) { + if (teardownErrors.length) console.error(new AggregateError(teardownErrors, "UI context wrapper teardown failed after the checks failed")); + throw failure; +} +if (teardownErrors.length) throw new AggregateError(teardownErrors, "UI context wrapper teardown failed"); diff --git a/scripts/ui-regression.ts b/scripts/ui-regression.ts index c276ec864..a11081a2e 100644 --- a/scripts/ui-regression.ts +++ b/scripts/ui-regression.ts @@ -8,47 +8,28 @@ import { chromium } from "playwright-core"; import type { BrowserContext, Page } from "playwright-core"; import { createServer } from "../server/index.ts"; import { herdrRpc, sessionSnapshot, workspaceCreate, workspaceClose } from "../server/herdr/client.ts"; -import type { PaneMoved, SessionSnapshot, WorkspaceCreated, WorktreeOpened } from "../shared/protocol.ts"; +import type { SessionSnapshot, WorkspaceCreated, WorktreeOpened } from "../shared/protocol.ts"; import type { Machine } from "../shared/machines.ts"; +import { MAX_ATTACHMENT_BYTES } from "../shared/attachments.ts"; import { alertsOffMarked, alertsState, runMoreItem } from "./header-more.ts"; -import { checkPushSettings } from "./push-settings-regression.ts"; -import { checkWakeLock } from "./wake-lock-regression.ts"; -import { checkSecretInput } from "./secret-input-regression.ts"; -import { checkTerminalCopy } from "./terminal-copy-regression.ts"; -import { checkPaneFind } from "./pane-find-regression.ts"; -import { checkUsageMeters } from "./usage-regression.ts"; -import { checkNotificationStartup } from "./notification-startup-regression.ts"; -import { checkNotificationView } from "./notification-view-regression.ts"; -import { checkMobileViewport } from "./mobile-viewport-regression.ts"; -import { checkTerminalPinch } from "./terminal-pinch-regression.ts"; -import { checkMobileTabs } from "./mobile-tabs-regression.ts"; -import { checkTabMenu } from "./tab-menu-regression.ts"; -import { sheetsSettled } from "./sheets-settled.ts"; import { routeDrained } from "./drained-route.ts"; import { checkTerminalFileInput } from "./terminal-file-input-regression.ts"; import { checkTerminalInput } from "./terminal-input-regression.ts"; import { checkSafariIme } from "./terminal-safari-ime-regression.ts"; +import { checkIpadIme } from "./terminal-ipad-ime-regression.ts"; import { checkHeldDraftPaneSwitch } from "./terminal-draft-regression.ts"; -import { checkDefaultView } from "./default-view-regression.ts"; -import { checkComposerReconnect } from "./composer-reconnect-regression.ts"; -import { checkDroplet } from "./droplet-regression.ts"; -import { checkSplitView } from "./split-view-regression.ts"; -import { checkTakeOver } from "./take-over-regression.ts"; -import { checkAlertSound } from "./alert-sound-regression.ts"; -import { checkBackgroundTabKeepsTerminalSize, checkChatKeepsTerminalSize, checkFocusedWindowReclaimsTerminalSize, checkPaneSwitchKeepsTerminalSize } from "./chat-size-regression.ts"; import { checkCommandBackspace } from "./terminal-command-backspace-regression.ts"; import { checkCtrlEnter } from "./terminal-ctrl-enter-regression.ts"; import { checkCommandArrows } from "./terminal-command-arrows-regression.ts"; import { checkFolderFilter } from "./folder-filter-regression.ts"; import { checkPaletteKeys } from "./palette-keys-regression.ts"; -import { checkUpdateNotice } from "./update-notice-regression.ts"; -import { checkTelemetrySettings } from "./telemetry-regression.ts"; import { UsageService } from "../server/usage.ts"; import { openSettingsPage } from "./settings-page.ts"; import { assertCspClean, watchCsp } from "./csp-violations.ts"; import type { CspWatch } from "./csp-violations.ts"; import { browserEvidencePage, browserEvidenceTracing, startBrowserEvidence } from "./browser-evidence.ts"; import type { BrowserEvidenceSession } from "./browser-evidence.ts"; +import { chromePath } from "./chrome-path.ts"; const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-web-ui-browser-"))); const workspaces: string[] = []; @@ -104,7 +85,7 @@ try { server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: join(root, "push"), usage: new UsageService(undefined, []) }); const origin = `http://127.0.0.1:${server.port}`; browser = await chromium.launch({ - executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", + executablePath: chromePath(), // English whatever the machine's own languages are (macOS Chrome takes them from the system) headless: true, args: ["--no-sandbox", "--accept-lang=en-US"], }); @@ -114,6 +95,21 @@ try { if (!localStorage.getItem("herdr-web-ui:settings")) localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" })); for (const id of ids) localStorage.setItem(`herdr-web-ui:view:${id}`, "chat"); }, panes); + // A hook for a check that changes the roster on a page already loaded: a route cannot reach the + // /api/machines/events stream the page has open, so `window.__rewriteMachinesFrame`, while set, + // rewrites each frame's data before the app reads it (the plan check below). + await context.addInitScript(() => { + const Native = window.EventSource; + window.EventSource = class extends Native { + override get onmessage() { return super.onmessage; } + override set onmessage(handler) { + super.onmessage = typeof handler !== "function" ? handler : (event: MessageEvent) => { + const rewrite = (window as unknown as { __rewriteMachinesFrame?: (data: string) => string }).__rewriteMachinesFrame; + handler.call(this, rewrite ? new MessageEvent("message", { data: rewrite(event.data) }) : event); + }; + } + }; + }); const page = await context.newPage(); const pageEvidence = evidenceDirectory ? await watchBrowserEvidence(context, page, "main-app") : undefined; @@ -151,7 +147,7 @@ try { page.on("pageerror", (error) => errors.push(error.message)); const painted = new Set(); const sockets: import("playwright-core").WebSocket[] = []; - const inputs: Array<{ type: "input" | "submit"; pane_id: string; text: string; delivery?: "immediate" | "queue" }> = []; + const inputs: Array<{ type: "input" | "submit"; pane_id: string; text: string; delivery?: "immediate" | "queue"; stop?: true }> = []; const pendingActions: Array<{ pane_id: string; pending_id: string; action: string }> = []; // keyed by pane, with the connection that said so: an earlier connection's input-ready says nothing about the current one const inputStates = new Map(); @@ -424,6 +420,14 @@ try { const offlineAgent = offlinePage.locator(`.agents-sidebar .agent-item[data-machine="local"][data-pane="${paneB}"] .agent-select`); await offlineAgent.waitFor(); assert.equal(await offlineAgent.isDisabled(), true, "a cached offline agent cannot select its pane"); + const offlineWorkspace = offlinePage.locator('.machine-offline').first(); + assert.equal(await offlineWorkspace.evaluate((element) => element.inert), true, + "offline workspace controls use native inert handling"); + assert.equal(await offlineWorkspace.evaluate((element) => { + const button = element.querySelector("button"); + button?.focus(); + return element.contains(document.activeElement); + }), false, "offline workspace controls cannot take keyboard focus"); const selectionBefore = await offlinePage.evaluate(() => sessionStorage.getItem("herdr-web-ui:selection")); await offlineAgent.dispatchEvent("click"); assert.equal(await offlinePage.evaluate(() => sessionStorage.getItem("herdr-web-ui:selection")), selectionBefore, @@ -477,14 +481,6 @@ try { } backgroundPeer = null; await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange"))); - await page.keyboard.press("ControlOrMeta+Shift+Comma"); - await openSettingsPage(page, "Appearance"); - const quietFinishes = page.getByRole("switch", { name: "Quiet opened finishes", exact: true }); - const quietBefore = await quietFinishes.getAttribute("aria-checked"); - if (quietBefore !== "true") await quietFinishes.click(); - await page.getByRole("button", { name: "Close settings", exact: true }).click(); - await waitingBadge.waitFor(); - assert.equal((await waitingBadge.textContent())?.trim(), "BG", "Quiet opened finishes cannot hide a background hold"); if (process.env.UI_EVIDENCE_DIR) { const viewport = page.viewportSize()!; const themeBefore = await page.evaluate(() => document.documentElement.dataset.theme); @@ -506,12 +502,6 @@ try { await page.setViewportSize(viewport); await page.evaluate((value) => { document.documentElement.dataset.theme = value; }, themeBefore); } - if (quietBefore !== "true") { - await page.keyboard.press("ControlOrMeta+Shift+Comma"); - await openSettingsPage(page, "Appearance"); - await page.getByRole("switch", { name: "Quiet opened finishes", exact: true }).click(); - await page.getByRole("button", { name: "Close settings", exact: true }).click(); - } await unrouteBackground(); await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange"))); await waitingBadge.waitFor({ state: "detached" }); @@ -525,17 +515,43 @@ try { { id: "3", label: "Write the panel", active: null, status: "pending", blocked_by: ["1"], owner: null, started_at: null, ended_at: null }, { id: "4", label: "Ship it", active: null, status: "pending", blocked_by: ["2", "3"], owner: null, started_at: null, ended_at: null }, ]; - const planned = async (target: typeof page): Promise<() => Promise> => { + type Roster = { machines: { snapshot?: { panes: { pane_id: string }[] } }[] }; + const withPlan = (body: Roster): Roster => { + for (const machine of body.machines) for (const pane of machine.snapshot?.panes ?? []) if (pane.pane_id === paneA) Object.assign(pane, { plan: { done: 1, total: 4, current: "Writing the parser" } }); + return body; + }; + // `events`: for a page not loaded yet. The roster also comes over /api/machines/events, whose + // first frame (and every roster change after it) is the PC's own snapshot, with no plan: one that + // lands after a poll takes the chip away again, and each one drops the poll in flight. That stream + // then carries the planned roster once and stays quiet (a reconnect waits an hour), so the only + // roster the page gets is the planned one. A page already loaded keeps the stream it has. + const planned = async (target: typeof page, { events = false } = {}): Promise<() => Promise> => { const unrouteMachines = await routeDrained(target, "**/api/machines", async (route) => { const response = await route.fetch(); - const body = await response.json() as { machines: { snapshot?: { panes: { pane_id: string }[] } }[] }; - for (const machine of body.machines) for (const pane of machine.snapshot?.panes ?? []) if (pane.pane_id === paneA) Object.assign(pane, { plan: { done: 1, total: 4, current: "Writing the parser" } }); - await route.fulfill({ response, json: body }); + await route.fulfill({ response, json: withPlan(await response.json() as Roster) }); }); + if (events) { + await target.route("**/api/machines/events", async (route) => { + const response = await route.fetch({ url: `${origin}/api/machines` }); + const roster = withPlan(await response.json() as Roster); + await route.fulfill({ status: 200, contentType: "text/event-stream", body: `retry: 3600000\ndata: ${JSON.stringify({ type: "machines", machines: roster.machines })}\n\n` }); + }); + } await target.route(`**/api/pane/plan?pane_id=${encodeURIComponent(paneA)}`, (route) => route.fulfill({ json: { plan: { steps: planSteps }, server_time: new Date().toISOString() } })); return unrouteMachines; }; const unroutePlanned = await planned(page); + // this page keeps the stream it has: any roster it pushes now (a session or layout change on the + // PC) carries the plan too, or that frame would take the chip away between waitFor and click + await page.evaluate(([pane, plan]) => { + (window as unknown as { __rewriteMachinesFrame?: (data: string) => string }).__rewriteMachinesFrame = (data) => { + let payload: { type?: string; machines?: { snapshot?: { panes: { pane_id: string }[] } }[] }; + try { payload = JSON.parse(data); } catch { return data; } + if (payload.type !== "machines") return data; + for (const machine of payload.machines ?? []) for (const p of machine.snapshot?.panes ?? []) if (p.pane_id === pane) Object.assign(p, { plan }); + return JSON.stringify(payload); + }; + }, [paneA, { done: 1, total: 4, current: "Writing the parser" }] as const); await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange"))); const planChip = page.getByRole("button", { name: /^Plan: 1 of 4 done/ }); await planChip.waitFor(); @@ -556,6 +572,7 @@ try { await planChip.click(); await until(async () => await planList.locator(".bg-step").count() === 4, "the plan's steps again"); await unroutePlanned(); + await page.evaluate(() => { delete (window as unknown as { __rewriteMachinesFrame?: unknown }).__rewriteMachinesFrame; }); await page.unroute(`**/api/pane/plan?pane_id=${encodeURIComponent(paneA)}`); await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange"))); await planList.waitFor({ state: "detached" }); @@ -570,7 +587,7 @@ try { const fileInputSocket = inputStates.get(paneA)?.socket; assert.ok(fileInputSocket, "the current pane has an input-ready attachment"); await checkTerminalFileInput(page, fileInputSocket); - const terminalInput = page.locator(".xterm-helper-textarea"); + const terminalInput = page.locator(".pane-frame.is-current .xterm-helper-textarea"); // Chrome on macOS pastes with ⌘V alone: its Ctrl+V pastes nothing, and must send nothing either const mac = process.platform === "darwin"; if (mac) { @@ -850,31 +867,6 @@ try { await blocked.close(); console.log("PASS a device that blocks notifications keeps the Alerts item as the switch for in-app alerts"); - await checkPushSettings(browser, origin); - await checkWakeLock(browser, origin, paneA); - await checkSecretInput(browser, origin); - await checkTerminalCopy(browser, origin); - await checkPaneFind(browser, origin); - await checkUsageMeters(browser, origin); - await checkNotificationStartup(browser, origin, paneA, paneB); - await checkNotificationView(browser, origin); - await checkMobileViewport(browser, origin, paneB); - await checkTerminalPinch(browser, origin, paneB); - await checkMobileTabs(browser, origin); - await checkTabMenu(browser, origin); - await checkDefaultView(browser, origin); - await checkComposerReconnect(browser, origin, paneB); - await checkDroplet(browser, origin); - await checkSplitView(browser, origin); - await checkTakeOver(browser, origin); - await checkAlertSound(browser, origin); - await checkChatKeepsTerminalSize(browser, origin); - await checkPaneSwitchKeepsTerminalSize(browser, origin); - await checkBackgroundTabKeepsTerminalSize(browser, origin); - await checkFocusedWindowReclaimsTerminalSize(browser, origin); - await checkUpdateNotice(browser, origin); - await checkTelemetrySettings(browser); - // The pending protocol has a real foreground agent and an owned byte recorder: merely // reporting an idle shell as working would not prove that server-side queueing happened. const pendingCwd = join(root, "pending"); mkdirSync(pendingCwd); @@ -935,6 +927,7 @@ try { assert.equal(await page.getByRole("button", { name: "Stop agent", exact: true }).count(), 0); await composer.press("Escape"); await until(() => pendingBytes() === `\u001b[200~${queuedTexts[0]}\u001b[201~\r\u001b`, "Escape with a draft stops the working agent"); + assert.deepEqual(inputs.at(-1), { type: "input", pane_id: pendingPane, text: "\u001b", stop: true }, "the composer's Stop goes out as a Stop"); assert.equal(await composer.inputValue(), "a draft while the agent works", "Escape leaves the draft in the box"); await composer.fill(""); // a follow-up sent between a reconnect and its snapshot is the new connection's own row, not an unconfirmed copy @@ -980,6 +973,8 @@ try { await page.reload(); await page.locator(".conn-live").waitFor(); await until(async () => await page.locator(".composer-queue-text").count() === 3, "legacy held rows restored"); assert.equal(await composer.inputValue(), ""); + // the reloaded page shows the last status it had until the server's "working" lands + await page.locator('.composer-status[data-status="working"]').waitFor(); assert.equal(await page.getByRole("button", { name: "Stop agent", exact: true }).count(), 1); assert.equal(await page.locator(".composer-action").count(), 1, "one primary action beside legacy recovery"); // the held rows sit on the input card's column, under one caption that counts them @@ -1357,41 +1352,73 @@ try { await composer.fill(""); console.log("PASS paths wait for a /command, typed or dictated, or an IME composition to finish, and selections keep their direction"); - // One pick takes as many files as Settings → Chat → Files at once says, and says what it left out. + // A path that does not fit whole is never cut: it waits, its chip and the status line say how many + // characters to remove, Send is off, and the path goes in once there is room. The count of + // characters left shows only near the limit. + const MAX_COMPOSER_CHARS = 20_000; // src/lib/compose.ts; importing it would load the app's modules here + const statusHint = page.locator(".composer-status-hint"); + const sendButton = page.getByRole("button", { name: "Send message", exact: true }); + held.clear(); + await composer.fill("x".repeat(MAX_COMPOSER_CHARS - 201)); + assert.equal(await statusHint.count(), 0, "no count far from the limit"); + await composer.fill("x".repeat(MAX_COMPOSER_CHARS - 5)); + await until(async () => (await statusHint.textContent())?.includes("5 characters left") === true, "the count near the limit"); + await pickFiles("fourteenth.txt"); + await until(() => held.has("fourteenth.txt"), "fourteenth upload in flight"); + await finish("fourteenth.txt"); + const roomless = page.locator(".composer-attachment.is-no-room"); + await roomless.waitFor(); + assert.match(await roomless.innerText(), /No room/); + await until(async () => /No room for fourteenth\.txt: remove \d+ characters/.test(await statusHint.textContent() ?? ""), "the status line says how much to cut"); + const short = Number(/remove (\d+) characters/.exec(await statusHint.textContent() ?? "")![1]); + assert.equal(await sendButton.isDisabled(), true, "Send waits for the path"); + assert.equal(await composer.inputValue(), "x".repeat(MAX_COMPOSER_CHARS - 5), "no part of the path went in"); + await composer.fill("x".repeat(MAX_COMPOSER_CHARS - 200)); + await until(async () => /^@\S*fourteenth-\S* x+$/.test(await composer.inputValue()), "the path goes in once there is room"); + assert.equal(short, /^@\S+ /.exec(await composer.inputValue())![0].length - 5, "the count was what the path needed"); + assert.equal(await roomless.count(), 0, "the chip is attached again"); + // Removing the file with no room lets the paths waiting behind it in, with no edit of the text. + const mentionLength = /^@\S+ /.exec(await composer.inputValue())![0].length - "fourteenth.txt".length; + const long = `fifteenth${"y".repeat(60)}.txt`; + await composer.fill("x".repeat(MAX_COMPOSER_CHARS - mentionLength - "sixteenth.txt".length - 20)); + await pickFiles(long, "sixteenth.txt"); + await until(() => held.has(long) && held.has("sixteenth.txt"), "fifteenth and sixteenth uploads in flight"); + await finish(long); + await roomless.waitFor(); + await finish("sixteenth.txt"); + assert.match(await roomless.innerText(), /No room/); + assert.equal(await sendButton.isDisabled(), true, "the short path waits behind the long one"); + assert.doesNotMatch(await composer.inputValue(), /sixteenth/u); + await page.getByRole("button", { name: `Remove ${long}`, exact: true }).click(); + await until(async () => /^@\S*sixteenth-\S* x+$/.test(await composer.inputValue()), "the short path goes in once the long one is removed"); + await until(async () => !(await sendButton.isDisabled()), "Send is back once every path is in"); + await composer.fill(""); + console.log("PASS a path that does not fit waits whole and says how much to cut, and the count shows only near the limit"); + // One pick takes up to 100 files, and says what it left out. await page.unroute("**/api/pane/image"); const limitedUploads: string[] = []; await page.route("**/api/pane/image", async (route) => { limitedUploads.push(route.request().postDataJSON().pane_id); await route.continue(); }); - const setAttachLimit = async (from: number, to: number) => { - await page.keyboard.press("ControlOrMeta+Shift+Comma"); - await openSettingsPage(page, "Chat"); - const button = page.getByRole("button", { name: to < from ? "Attach fewer files at once" : "Attach more files at once", exact: true }); - for (let step = 0; step < Math.abs(from - to); step += 1) await button.click(); - assert.equal(await page.getByRole("group", { name: "Files at once", exact: true }).locator("output").textContent(), String(to)); - await page.getByRole("button", { name: "Close settings", exact: true }).click(); - }; const ready = page.locator(".composer-attachment.is-ready"); const readyBefore = await ready.count(); await page.locator('input[type="file"]').setInputFiles(["a", "b", "c", "d", "e", "f"].map((name) => ({ name: `${name}.png`, mimeType: "image/png", buffer: png }))); - await until(async () => (await ready.count()) === readyBefore + 6, "six attachments uploaded at the default limit"); - assert.deepEqual(limitedUploads, Array(6).fill(paneB), "every picked file is uploaded at the default limit"); + await until(async () => (await ready.count()) === readyBefore + 6, "six attachments uploaded"); + assert.deepEqual(limitedUploads, Array(6).fill(paneB), "every picked file is uploaded"); assert.equal(await page.getByRole("alert").filter({ hasText: /the first \d+ of \d+ files/ }).count(), 0, "nothing was left out"); limitedUploads.length = 0; - await setAttachLimit(20, 2); // the first file is over the size limit: its error and the count left out both stay in the note await page.locator('input[type="file"]').setInputFiles([ - { name: "huge.bin", mimeType: "application/octet-stream", buffer: Buffer.alloc(8 * 1024 * 1024 + 1) }, - ...["one", "two"].map((name) => ({ name: `${name}.png`, mimeType: "image/png", buffer: png })), + { name: "huge.bin", mimeType: "application/octet-stream", buffer: Buffer.alloc(MAX_ATTACHMENT_BYTES + 1) }, + ...Array.from({ length: 100 }, (_, index) => ({ name: `many-${index}.png`, mimeType: "image/png", buffer: png })), ]); - await until(async () => (await ready.count()) === readyBefore + 7, "the file within both limits uploaded"); + await until(async () => (await ready.count()) === readyBefore + 6 + 99, "the files within both limits uploaded"); const note = page.getByRole("alert").filter({ hasText: "Too large to attach: huge.bin" }); await note.waitFor(); - assert.match(await note.innerText(), /the first 2 of 3 files were taken/); - assert.deepEqual(limitedUploads, [paneB], "only the files within the limit are uploaded"); - await setAttachLimit(2, 20); - console.log("PASS one pick attaches up to the chosen number of files and names what it left out"); + assert.match(await note.innerText(), /the first 100 of 101 files were taken/); + assert.deepEqual(limitedUploads, Array(99).fill(paneB), "only the files within the limit are uploaded"); + console.log("PASS one pick attaches up to 100 files and names what it left out"); let releaseCreate!: () => void; const createGate = new Promise((resolve) => { releaseCreate = resolve; }); @@ -1434,6 +1461,13 @@ try { workspaces.push(repoWorkspace.workspace.workspace_id); const repoRow = workspaceHeader(repoWorkspace.workspace.workspace_id); await repoRow.waitFor(); + // herdr has not marked this workspace as a repository's yet: its row is where the first worktree starts + await repoRow.hover(); + await repoRow.locator(".row-menu-toggle").click(); + const unmarkedRepoMenu = page.getByRole("menu"); + await unmarkedRepoMenu.getByRole("menuitem", { name: "New worktree", exact: true }).waitFor(); + await page.keyboard.press("Escape"); + await unmarkedRepoMenu.waitFor({ state: "detached" }); const initialResponse = await fetch(`${origin}/api/worktree/create`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ workspace_id: repoWorkspace.workspace.workspace_id, branch: "browser/initial", label: "Existing checkout" }), @@ -1623,7 +1657,7 @@ try { // A second tab from the tab strip's +: the dialog is New tab, with the workspace's folder shown // and not asked for; the new pane opens, the workspace stays one row, and a strip over the pane - // lists both tabs from then on. The header's More menu has New tab too, and opens the same dialog. + // lists both tabs from then on. // The new pane's terminal takes the focus once it paints, which would close a menu opened before. await until(() => painted.has(created.pane_id), "created pane paint"); await page.locator(`.pane-select[title^="${created.pane_id} —"]`).click(); @@ -1667,7 +1701,7 @@ try { await until(async () => (await page.locator(`.pane-select[title^="${created.pane_id} —"]`).getAttribute("aria-current")) === "true", "the first tab opens its pane again"); assert.equal(await strip.getByRole("tab", { selected: true }).textContent(), "Tab 1"); // reopened right after a creation: its fields are live and Escape puts it away at once - await runMoreItem(page, "New tab"); + await page.locator(".tab-strip-add").click(); await tabDialog.waitFor(); assert.equal(await tabDialog.getByRole("button", { name: "Start", exact: true }).isDisabled(), false, "a reopened dialog is not left pending"); await page.keyboard.press("Escape"); @@ -1702,6 +1736,10 @@ try { await page.keyboard.press("F2"); await tabName.fill(" "); await page.keyboard.press("Enter"); + // a blank name is refused and said so: the field stays for another try, and Escape keeps the old name + await page.locator(".tab-strip-error").getByText("A tab needs a name.", { exact: true }).waitFor(); + assert.equal(await tabName.count(), 1, "a blank name keeps the field open"); + await page.keyboard.press("Escape"); await tabName.waitFor({ state: "detached" }); assert.deepEqual(await strip.getByRole("tab").allTextContents(), ["first", "second"]); assert.equal((await tabsInHerdr()).join(), "first,second"); @@ -1716,18 +1754,10 @@ try { await tabName.fill("build"); await page.keyboard.press("Enter"); await until(async () => (await tabsInHerdr()).join() === "first,build", "the menu's rename reaches herdr"); - // On a touch screen the open tab carries a menu button: the same actions, as a sheet. - // A dozen more tabs first: the move menu lists every one, and the sheet (92% of the screen at - // most, its overflow hidden) must scroll them between its head and Cancel, which stay in reach - const manyTabs: string[] = []; - // herdr puts a new tab after its own focused one, which the web's selection no longer moves: - // focus build there, so the dozen land after it and build sits between siblings + // One new neighbour puts build between two tabs for both adjacent move actions. await herdrRpc("pane.focus", { pane_id: createdTab.pane_id }); - for (let index = 1; index <= 12; index += 1) { - const made = await herdrRpc<{ tab: { tab_id: string } }>("tab.create", { workspace_id: created.workspace_id, label: `many-${index}`, focus: false }); - manyTabs.push(made.tab.tab_id); - } - await until(async () => (await tabsInHerdr()).length === 14, "herdr has the dozen tabs"); + const neighbour = await herdrRpc<{ tab: { tab_id: string } }>("tab.create", { workspace_id: created.workspace_id, label: "neighbour", focus: false }); + await until(async () => (await tabsInHerdr()).length === 3, "build has two neighbours"); const tabPhone = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); const tabPhonePage = await tabPhone.newPage(); const tabPhoneEvidence = evidenceDirectory ? await watchBrowserEvidence(tabPhone, tabPhonePage, "touch-tab-menu") : undefined; @@ -1742,35 +1772,14 @@ try { const tabSheet = tabPhonePage.getByRole("dialog", { name: "build", exact: true }); await tabSheet.waitFor(); const touchTabItems = await tabSheet.locator(".row-sheet-item").allTextContents(); - assert.deepEqual(touchTabItems.slice(-5), ["Move tab left", "Move tab right", "New tab", "Rename tab", "Close tab"], "a tab between siblings offers both adjacent moves on touch"); + assert.deepEqual(touchTabItems.slice(-4), ["Move tab left", "Move tab right", "Rename tab", "Close tab"], "a tab between siblings offers both adjacent moves on touch"); assert.ok(touchTabItems.includes("Rename pane") && touchTabItems.includes("Close pane"), "the touch menu carries the open pane's actions"); await tabSheet.getByRole("button", { name: "Cancel", exact: true }).tap(); await tabSheet.waitFor({ state: "detached" }); - await runMoreItem(tabPhonePage, "Move pane to…"); - const moveSheet = tabPhonePage.getByRole("dialog", { name: /^Move .+ to$/ }); - await moveSheet.waitFor(); - await sheetsSettled(tabPhonePage); - const moveItems = moveSheet.locator(".row-sheet-item"); - assert.ok(await moveItems.count() >= 15, `the move sheet lists the dozen tabs and the other workspaces (${await moveItems.count()} items)`); - const moveList = moveSheet.locator(".row-sheet-items"); - const moveListSize = await moveList.evaluate((list) => ({ scroll: list.scrollHeight, client: list.clientHeight })); - assert.ok(moveListSize.scroll > moveListSize.client, `the items scroll inside the sheet (${moveListSize.scroll} > ${moveListSize.client})`); - const moveCancel = moveSheet.getByRole("button", { name: "Cancel", exact: true }); - const moveCancelBox = await moveCancel.boundingBox(); - assert.ok(moveCancelBox !== null && moveCancelBox.y + moveCancelBox.height <= 844, `Cancel stays on the screen under the list (${JSON.stringify(moveCancelBox)})`); - const lastMoveItem = moveItems.last(); - assert.equal(await lastMoveItem.textContent(), "New workspace"); - await lastMoveItem.scrollIntoViewIfNeeded(); - const lastMoveItemBox = await lastMoveItem.boundingBox(); - const moveListBox = await moveList.boundingBox(); - assert.ok(lastMoveItemBox !== null && moveListBox !== null && lastMoveItemBox.y >= moveListBox.y && lastMoveItemBox.y + lastMoveItemBox.height <= moveListBox.y + moveListBox.height + 1, - `the list scrolls its last item into view (${JSON.stringify(lastMoveItemBox)} in ${JSON.stringify(moveListBox)})`); - await moveCancel.tap(); - await moveSheet.waitFor({ state: "detached" }); if (tabPhoneEvidence) await tabPhoneEvidence.finish(); await tabPhone.close(); - for (const tabId of manyTabs) await herdrRpc("tab.close", { tab_id: tabId }); - await until(async () => (await tabsInHerdr()).join() === "first,build", "the dozen tabs are closed again"); + await herdrRpc("tab.close", { tab_id: neighbour.tab.tab_id }); + await until(async () => (await tabsInHerdr()).join() === "first,build", "the extra neighbour is closed again"); // a tab whose agent is at work asks before it closes; a no leaves it await herdrRpc("pane.report_agent", { pane_id: createdTab.pane_id, source: "manual", agent: "codex", state: "working" }); await strip.locator('.tab-strip-dot[data-status="working"]').waitFor(); @@ -1810,42 +1819,6 @@ try { await until(async () => await strip.getByRole("tab", { name: "first", exact: true }).evaluate((tab) => tab === document.activeElement), "the surviving tab receives focus"); console.log("PASS a tab is renamed by a double-click, F2 and its menu, and closed from its menu and Delete, asking first while its agent works"); - // A pane moved into another workspace answers to a new id, and the app follows it even when the - // roster without the old pane reaches the browser before the move's answer does: a roster alone - // sends a selection whose pane is gone back to herdr's focus, as it must for a closed pane. The - // answer is held here until the app has seen that roster and confirmed it against this PC. - const mover = await herdrRpc<{ root_pane: { pane_id: string } }>("tab.create", { workspace_id: created.workspace_id, label: "mover", focus: false }); - const moverId = mover.root_pane.pane_id; - await strip.getByRole("tab", { name: "mover", exact: true }).click(); - await until(async () => (await page.locator(`.pane-select[title^="${moverId} —"]`).getAttribute("aria-current")) === "true", "the mover tab's pane is open"); - assert.notEqual((await sessionSnapshot()).focused_pane_id, moverId, "herdr's focus is elsewhere: a fallback could not land on the moved pane by chance"); - // the fallback's own read of this PC, without the pane: what the answer waits for - const absenceConfirmed = page.waitForResponse(async (response) => { - if (response.request().method() !== "GET" || !/\/api\/session(?:\?|$)/.test(response.url())) return false; - try { return !(await response.text()).includes(`"${moverId}"`); } catch { return false; } - }); - let lateAnswer: PaneMoved | null = null; - await page.route("**/api/pane/move", async (route) => { - const answer = await route.fetch(); - lateAnswer = await answer.json() as PaneMoved; - await absenceConfirmed; - // the fallback's render, had the read released one: a frame and a tick after it - await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => setTimeout(done, 0)))); - await route.fulfill({ response: answer, body: JSON.stringify(lateAnswer) }); - }); - await runMoreItem(page, "Move pane to…"); - const moveMenu = page.getByRole("menu", { name: /^Move .+ to$/ }); - await moveMenu.waitFor(); - await moveMenu.getByRole("menuitem", { name: "herdr-web-ui-test-browser-b", exact: true }).click(); - await until(() => lateAnswer !== null, "herdr answered the move"); - const movedId = lateAnswer!.pane.pane_id; - assert.notEqual(movedId, moverId, "a pane that changes workspace gets a new id"); - await until(async () => (await page.locator(`.pane-select[title^="${movedId} —"]`).getAttribute("aria-current")) === "true", "the app follows the moved pane under its new id, late answer or not"); - await page.unroute("**/api/pane/move"); - assert.equal((await tabsInHerdr()).join(), "first", "the emptied tab closed behind the pane"); - await herdrRpc("tab.close", { tab_id: lateAnswer!.created_tab!.tab_id }); - console.log("PASS a pane moved into another workspace is followed under its new id, also when the roster outruns the answer"); - // herdr 0.9.0 reports Codex's first directory-trust menu as idle. Exercise a // live, owned PTY menu so the chat controls cannot depend on a blocked badge. await selectPane(paneB); @@ -1954,14 +1927,18 @@ try { // Server-owned Send now drops the old suggestion itself, before a delayed prompt read returns. await selectPane(pendingPane); + await page.keyboard.press("ControlOrMeta+Shift+Comma"); + await openSettingsPage(page, "Chat"); + await page.getByRole("switch", { name: "Send queued messages automatically", exact: true }).uncheck(); + await page.getByRole("button", { name: "Close settings", exact: true }).click(); await reportPending("working"); await page.locator('.composer-status[data-status="working"]').waitFor(); await pendingReady(pendingPane); await composer.fill("# queued before the suggestion"); await page.getByRole("button", { name: "Send message", exact: true }).click(); await until(async () => await pendingRows.count() === 1, "suggestion test pending input accepted"); - // A pane switch ends this item's automatic lease. Claude's ready-only suggestion - // can then appear beside the retained server-owned item without auto-dispatching it. + // Explicit hold keeps the suggestion beside a server-owned item without dispatching it. + // Navigation preserves its held state and the same PC connection's recovery actions. await selectPane(paneA); await selectPane(pendingPane); await page.locator('.pending-message[data-state="held"]').waitFor(); await pendingReady(pendingPane); @@ -1981,6 +1958,10 @@ try { await pendingRows.waitFor({ state: "hidden" }); await page.unroute(`**/api/pane/prompt?pane_id=${encodeURIComponent(pendingPane)}`); await herdrRpc("pane.send_text", { pane_id: pendingPane, text: "\u0003" }); + await page.keyboard.press("ControlOrMeta+Shift+Comma"); + await openSettingsPage(page, "Chat"); + await page.getByRole("switch", { name: "Send queued messages automatically", exact: true }).check(); + await page.getByRole("button", { name: "Close settings", exact: true }).click(); await workspaceClose(pendingWorkspace.workspace.workspace_id); workspaces.splice(workspaces.indexOf(pendingWorkspace.workspace.workspace_id), 1); await selectPane(paneA); @@ -1992,9 +1973,29 @@ try { // (after the steps that take the newest socket for the page's own: the other half opens one) { await selectPane(paneA); + // The drag goes in steps, each waited on, as split-view-regression.ts drags. One dragTo jumps + // from the row to the drop point in a single move and drops at once: the drop then rests on the + // one dragover sent as the drag starts, and nothing checks that the page took it (on a busy + // runner it left no split). Here the row crosses the native drag threshold, App shows its guide + // (it saw the dragstart), the area is measured after that, and the drop waits for a dragover on + // the drop point to take the pane for the right half. const area = page.locator(".pane-split"); + const source = page.locator(`.pane-select[title^="${paneB} —"]`); + await source.scrollIntoViewIfNeeded(); + const from = (await source.boundingBox())!; + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(from.x + from.width / 2 + 8, from.y + from.height / 2 + 4, { steps: 2 }); + await page.waitForFunction(() => document.querySelector(".split-drop")?.classList.contains("is-visible") === true); const box = (await area.boundingBox())!; - await page.locator(`.pane-select[title^="${paneB} —"]`).dragTo(area, { targetPosition: { x: box.width * 0.8, y: box.height / 2 } }); + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2, { steps: 8 }); + // crossing the transcript's children can end on a dragenter alone: a move on the same element sends the dragover + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2); + await page.waitForFunction(() => { + const guide = document.querySelector(".split-drop"); + return guide?.classList.contains("is-visible") === true && guide.getAttribute("data-side") === "right"; + }); + await page.mouse.up(); await until(async () => await page.locator(".pane-slot").count() === 2, "two halves after the drop"); const half = (side: string) => page.locator(`.pane-slot[data-side="${side}"]`); await half("left").getByRole("log", { name: `conversation of ${paneA}`, exact: true }).waitFor(); @@ -2049,7 +2050,7 @@ try { await planPhone.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", defaultView: "chat" }))); const phone = await planPhone.newPage(); phone.on("pageerror", (error) => errors.push(error.message)); - const unroutePhonePlanned = await planned(phone); + const unroutePhonePlanned = await planned(phone, { events: true }); await phone.goto(`${origin}/?pane=${encodeURIComponent(paneA)}`); await phone.locator(".conn-live").waitFor(); const phonePlan = phone.getByRole("button", { name: /^Plan: 1 of 4 done/ }); @@ -2093,6 +2094,16 @@ try { await mobilePage.getByRole("textbox", { name: "Message", exact: true }).waitFor(); console.log("PASS a pane picked on a phone waits for a tap before raising the keyboard"); + // Escape with the drawer open closes it and reaches nothing else, wherever the focus is + await mobilePage.evaluate(() => { (window as unknown as { escapes: number }).escapes = 0; window.addEventListener("keydown", (e) => { if (e.key === "Escape") (window as unknown as { escapes: number }).escapes++; }); }); + await mobilePage.locator('button[aria-controls="workspace-drawer"]').click(); + await mobilePage.locator("#workspace-drawer.is-open").waitFor(); + await mobilePage.getByRole("textbox", { name: "Message", exact: true }).focus(); + await mobilePage.keyboard.press("Escape"); + await mobilePage.locator("#workspace-drawer.is-open").waitFor({ state: "detached" }); + assert.equal(await mobilePage.evaluate(() => (window as unknown as { escapes: number }).escapes), 0, "Escape that closes the drawer does not reach the page"); + console.log("PASS Escape closes a phone's drawer and goes no further"); + // Claude's suggestion on a phone is the placeholder and a chip that fills the box, until Settings turns the chip off const promptRoute = `**/api/pane/prompt?pane_id=${encodeURIComponent(paneB)}`; const suggest = { json: { prompt: null, suggestion: "run the tests" } }; @@ -2105,27 +2116,13 @@ try { assert.equal(await mobileComposer.inputValue(), "run the tests", "the chip puts the suggestion in the box"); await mobilePage.unroute(promptRoute); try { await mobileEvidence?.finish(); } finally { await mobile.close(); } - const plainPhone = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); - await plainPhone.addInitScript(() => { localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ showSuggestionChip: false })); }); - const plainPage = await plainPhone.newPage(); - const plainEvidence = evidenceDirectory ? await watchBrowserEvidence(plainPhone, plainPage, "suggestion-chip-off") : undefined; - csp.push(await watchCsp(plainPage)); - plainPage.on("pageerror", (error) => errors.push(error.message)); - await plainPage.route(promptRoute, (route) => route.fulfill(suggest)); - await plainPage.goto(`${origin}/?pane=${encodeURIComponent(paneB)}`); - await plainPage.locator(".conn-live").waitFor(); - await plainPage.getByTitle("Chat transcript (⌘⇧J)", { exact: true }).click(); - const plainComposer = plainPage.getByRole("textbox", { name: "Message", exact: true }); - await until(async () => await plainComposer.getAttribute("placeholder") === "run the tests", "the suggestion stays the placeholder with the chip off"); - assert.equal(await plainPage.getByTitle("Use the suggestion", { exact: true }).count(), 0, "no suggestion chip once Settings turns it off"); - if (plainEvidence) await plainEvidence.finish(); - await plainPhone.close(); assert.equal(errors.length, 0, errors.join("\n")); assertCspClean(csp, "the suggestion chip loads with no CSP violation"); - console.log("PASS a phone offers Claude's suggestion as a chip until Settings turns it off"); + console.log("PASS a phone offers Claude's suggestion as a chip"); await checkTerminalInput(browser, origin, paneA, paneB); await checkSafariIme(browser, origin, paneA, paneB); + await checkIpadIme(browser, origin, paneA, paneB); await checkHeldDraftPaneSwitch(browser, origin, paneA, paneB); // the terminal lens on a touch screen: an input line sends whole lines; the grid raises no keyboard diff --git a/scripts/update-browser-qa.ts b/scripts/update-browser-qa.ts index 53f548864..b266b6634 100644 --- a/scripts/update-browser-qa.ts +++ b/scripts/update-browser-qa.ts @@ -9,6 +9,7 @@ import { runCommand } from "../server/updater.ts"; import { workspaceCreate, workspaceClose, sessionSnapshot } from "../server/herdr/client.ts"; import type { UpdateStatus } from "../shared/update.ts"; import { openSettingsPage } from "./settings-page.ts"; +import { chromePath } from "./chrome-path.ts"; const source = resolve(import.meta.dir, ".."); const temp = mkdtempSync(join(tmpdir(), "herdr-update-browser-")); @@ -57,7 +58,7 @@ try { const workspace = await workspaceCreate({ cwd: temp, label: "herdr-web-ui-test-update-browser" }); workspaceId = workspace.workspace.workspace_id; const paneId = workspace.root_pane.pane_id; - browser = await chromium.launch({ executablePath: process.env["CHROME_PATH"] ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); + browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); const errors: string[] = []; page.on("pageerror", error => errors.push(error.message)); diff --git a/scripts/update-notice-regression.ts b/scripts/update-notice-regression.ts index ac44e9fc2..79cdbd2e6 100644 --- a/scripts/update-notice-regression.ts +++ b/scripts/update-notice-regression.ts @@ -69,8 +69,12 @@ export async function checkUpdateNotice(browser: Browser, origin: string, shots? await fits("available"); await oneLine("available"); await shot("1-available"); + // the line says it started as the button is pressed, before the request it sends is counted here + const installAnswered = () => page.waitForResponse((response) => new URL(response.url()).pathname === "/api/updates/install"); + const firstInstall = installAnswered(); await page.getByRole("button", { name: "Update", exact: true }).click(); await page.getByText("Starting the update…", { exact: true }).waitFor(); + await firstInstall; assert.equal(installs, 1, "one tap asks for the install"); assert.equal(await page.getByRole("dialog", { name: "Settings" }).count(), 0, "Settings stays closed"); @@ -93,8 +97,10 @@ export async function checkUpdateNotice(browser: Browser, origin: string, shots? await page.getByRole("button", { name: "Details", exact: true }).click(); await page.getByRole("dialog", { name: "Settings" }).getByText("bun run build failed: fixture").waitFor(); await page.getByRole("button", { name: "Close settings", exact: true }).click(); + const secondInstall = installAnswered(); await page.getByRole("button", { name: "Try again", exact: true }).click(); await page.getByText("Starting the update…", { exact: true }).waitFor(); + await secondInstall; assert.equal(installs, 2, "Try again asks for the install again"); // the check an install starts with fails: nothing is available any more, and the line still says so status = { ...status, phase: "error", available: false, error: "git fetch failed: fixture" }; diff --git a/scripts/usage-regression.ts b/scripts/usage-regression.ts index d09acf8f6..426fdfa73 100644 --- a/scripts/usage-regression.ts +++ b/scripts/usage-regression.ts @@ -7,7 +7,7 @@ import type { UsageReport } from "../shared/protocol.ts"; import { openSettingsPage } from "./settings-page.ts"; const at = (hours: number): string => new Date(Date.now() + hours * 3600_000).toISOString(); -/** Built when it is asked for: its times, and the paces measured from them, hold however late in the run that is. */ +/** Built when it is asked for: its times hold however late in the run that is. */ const report = (): UsageReport => ({ providers: [ { id: "claude", key: "claude:u1", account: "me@example.com", plan: "max", problem: null, checked_at: at(0), windows: [ @@ -31,19 +31,24 @@ const report = (): UsageReport => ({ ], }); -async function staged(context: BrowserContext): Promise { +/** a header the check puts on every request of the page it reloads: what that page asked for, whenever it is counted */ +const RELOADED = "x-usage-qa-reloaded"; + +async function staged(context: BrowserContext): Promise<{ asked: string[]; afterReload: string[] }> { const asked: string[] = []; + const afterReload: string[] = []; await context.route("**/api/usage*", (route) => { asked.push(new URL(route.request().url()).search); + if (route.request().headers()[RELOADED]) afterReload.push(route.request().url()); return route.fulfill({ json: report() }); }); - return asked; + return { asked, afterReload }; } export async function checkUsageMeters(browser: Browser, origin: string): Promise { const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: "en-US" }); try { - const asked = await staged(context); + const { asked, afterReload } = await staged(context); const page = await context.newPage(); const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); @@ -63,27 +68,12 @@ export async function checkUsageMeters(browser: Browser, origin: string): Promis await page.keyboard.press("Escape"); await strip.waitFor(); - // the pace is a setting of its own, off until chosen: no warning in the strip, no red hairline on a low share, no mark or rate in the popover - assert.doesNotMatch(await strip.getAttribute("aria-label") ?? "", /Runs out/); - assert.equal(await page.locator(".usage-chip.is-fast").count(), 0); - await strip.click(); - await page.getByRole("dialog", { name: "Subscription usage", exact: true }).locator(".usage-row").first().waitFor(); - assert.equal(await page.locator(".usage-bar-pace, .usage-row-pace").count(), 0); - await page.keyboard.press("Escape"); - await settingsButton.click(); - await openSettingsPage(page, "Subscription usage"); - await page.getByRole("switch", { name: "Show pace", exact: true }).click(); - await page.keyboard.press("Escape"); - await page.locator(".usage-chip.is-fast").first().waitFor(); - - // three chips and "+4" past four accounts, in the server's order, each its plan's week; one near its limit is red; two Codex accounts apart; - // a week running out before its reset says when, and its hairline turns red while its share is still low + // three chips and "+4" past four accounts, in the server's order, each its plan's week; one near its limit is red; two Codex accounts apart assert.equal(await page.locator(".usage-chip").count(), 3); assert.equal(await page.locator(".usage-more").textContent(), "+4"); assert.match(await strip.getAttribute("aria-label") ?? "", - /^Subscription usage: Claude · me@example\.com 63% \(Runs out in 2d 1h\), Codex · me@work\.example 91% \(Runs out in 6h \d+m\), Codex · me@example\.com 30%, Cursor 20%, Copilot · me 0%, Grok 0%, Antigravity —$/); + /^Subscription usage: Claude · me@example\.com 63%, Codex · me@work\.example 91%, Codex · me@example\.com 30%, Cursor 20%, Copilot · me 0%, Grok 0%, Antigravity —$/); assert.equal(await page.locator(".usage-chip").nth(1).evaluate((chip) => chip.classList.contains("is-high")), true); - assert.deepEqual(await page.locator(".usage-chip").evaluateAll((chips) => chips.map((chip) => chip.classList.contains("is-fast"))), [true, true, false]); assert.equal(await strip.evaluate((el) => el.scrollWidth <= el.clientWidth), true, "the chips fit beside Settings"); const [settings, meters] = await Promise.all([page.locator(".sidebar-footer-row .sidebar-footer-action").boundingBox(), strip.boundingBox()]); assert.ok(settings && meters && settings.x + settings.width <= meters.x, "Settings and the meters do not overlap"); @@ -95,14 +85,6 @@ export async function checkUsageMeters(browser: Browser, origin: string): Promis assert.equal(await popover.getByRole("meter").count(), 9); assert.equal(await popover.locator(".usage-account").first().textContent(), "me@example.com"); assert.equal(await popover.locator(".usage-row.is-high").count(), 1); - // the pace of every window whose start is known: a marker where an even pace would be, the rate, and when it runs out if before the reset - assert.equal(await popover.locator(".usage-bar-pace").count(), 4); - const paces = await popover.locator(".usage-row-pace").allTextContents(); - [/^15%\/h$/, /^18%\/day · Runs out in 2d 1h$/, /^2\.\d%\/h$/, /^32%\/day · Runs out in 6h \d+m$/].forEach((pattern, index) => assert.match(paces[index] ?? "", pattern)); - assert.equal(paces.length, 4); - const marker = () => popover.locator(".usage-bar-pace").first().evaluate((el) => parseFloat((el as HTMLElement).style.left)); - const usedMarker = await marker(); - assert.ok(usedMarker > 55 && usedMarker < 58, `a session 2.8 of 5 hours in puts the marker 56% along, not ${usedMarker}%`); assert.equal(await popover.locator(".usage-note.is-problem").count(), 1, "only the expired sign-in reads as an error"); if (process.env.UI_EVIDENCE_DIR) { mkdirSync(process.env.UI_EVIDENCE_DIR, { recursive: true }); @@ -124,14 +106,12 @@ export async function checkUsageMeters(browser: Browser, origin: string): Promis await page.getByRole("button", { name: "Move Cursor up", exact: true }).click(); await page.keyboard.press("Escape"); assert.equal(await strip.getAttribute("aria-label"), - "Subscription usage: Claude · me@example.com 37% left (Runs out in 2d 1h), Cursor 80% left, Codex · me@example.com 70% left, Copilot · me 100% left, Grok 100% left, Antigravity —"); + "Subscription usage: Claude · me@example.com 37% left, Cursor 80% left, Codex · me@example.com 70% left, Copilot · me 100% left, Grok 100% left, Antigravity —"); assert.deepEqual(await page.locator(".usage-chip-value").allTextContents(), ["37%", "80%", "70%"]); await strip.click(); assert.deepEqual((await names()).slice(0, 3), ["Claude · me@example.com", "Cursor", "Codex · me@example.com"], "a hidden account is left out of the popover too"); assert.equal(await popover.locator(".usage-provider").count(), 6); assert.equal(await popover.locator(".usage-row-value").first().textContent(), "58% left"); - const leftMarker = await marker(); - assert.ok(leftMarker > 42 && leftMarker < 45, `counting what is left, the marker sits where an even pace leaves 44%, not ${leftMarker}%`); if (process.env.UI_EVIDENCE_DIR) await page.locator(".sidebar-shell").screenshot({ path: join(process.env.UI_EVIDENCE_DIR, "usage-popover-left.png") }); await page.keyboard.press("Escape"); await settingsButton.click(); @@ -151,10 +131,13 @@ export async function checkUsageMeters(browser: Browser, origin: string): Promis await openSettingsPage(page, "Subscription usage"); await toggle.click(); await strip.waitFor({ state: "detached" }); - const before = asked.length; + // a request Settings made before the switch can still be on its way here, so the count is of + // the reloaded page's own requests, not of what arrives after the reload + await context.setExtraHTTPHeaders({ [RELOADED]: "1" }); await page.reload(); await page.locator(".sidebar-footer").waitFor(); - assert.equal(asked.length, before, "a hidden strip asks for nothing"); + assert.deepEqual(afterReload, [], "a hidden strip asks for nothing"); + await context.setExtraHTTPHeaders({}); assert.deepEqual(errors, []); } finally { await context.close(); diff --git a/scripts/virtual-source-browser-qa.ts b/scripts/virtual-source-browser-qa.ts new file mode 100644 index 000000000..81bcf3daa --- /dev/null +++ b/scripts/virtual-source-browser-qa.ts @@ -0,0 +1,515 @@ +/** + * The virtualized source viewer (VirtualSource) in the built app, against a file server that + * answers ranges as the real one does: bounded rows for a file of 120 000+ lines at the top, the + * middle and the end; a far line marked and in view with wrap off and on and across a resize; + * native selection and a real copy that keep blank and trailing lines and leave the gutter out; + * the whole source copied from the text; truthful byte and line cuts and the colors notice; the + * highlight, wrap and theme settings; close and reopen; and a remote PC's file. + * + * The browser is Playwright's headless shell, whose clipboard is its own: a real Cmd+C here never + * touches the clipboard of the PC running the check. + */ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium, type Page } from "playwright-core"; +import { build as viteBuild } from "vite"; +import { serveStatic } from "../server/static.ts"; +import { formatBytes } from "../src/lib/bridgeProgress.ts"; +import { SOURCE_PREVIEW_LINES, TEXT_PREVIEW_BYTES } from "../src/lib/filePreviewText.ts"; +import { VIRTUAL_HIGHLIGHT_LINES, VIRTUAL_SOURCE_LINES } from "../src/lib/sourcePreview.ts"; + +/** Numbered TypeScript lines, a blank one every tenth and a long one every 250th; the text ends with a blank line and its newline. */ +function numberedSource(lines: number, longLines: boolean): string { + const out: string[] = []; + for (let line = 1; line <= lines; line++) { + if (line % 10 === 0) out.push(""); + else if (longLines && line % 250 === 1) out.push(`// ${"a long comment that wraps on a phone ".repeat(12)}${line}`); + else out.push(`export const line_${String(line).padStart(6, "0")} = ${line}; // numbered line ${line}`); + } + return `${out.join("\n")}\n`; +} + +const FAR_LINE = 123_456; +const BIG_LINES = 123_500; +const files: Record = { + "/src/big.ts": numberedSource(BIG_LINES, true), + "/src/mid.ts": numberedSource(3_000, false), + "/src/small.ts": numberedSource(50, false), + "/src/lines.txt": "x\n".repeat(SOURCE_PREVIEW_LINES + 50_000), + "/src/huge.log": `${"0123456789".repeat(9)}\n`.repeat(Math.ceil((9 * 1024 * 1024) / 91)), + "/docs/long.md": "# Notes\n\nA paragraph of ordinary notes, with *emphasis* and a [link](./other.md).\n\n".repeat(8_000), + "/docs/notes.md": "## Section\n\n- an item of the list\n\n".repeat(800), +}; +assert.ok(files["/src/big.ts"]!.length < 8 * 1024 * 1024 && files["/src/big.ts"]!.split("\n").length === BIG_LINES + 1, "big.ts is whole under the source budget"); +assert.ok(files["/src/mid.ts"]!.length <= TEXT_PREVIEW_BYTES && 3_000 <= VIRTUAL_HIGHLIGHT_LINES && 3_000 > VIRTUAL_SOURCE_LINES, "mid.ts is virtualized and colorable"); +assert.ok(files["/docs/long.md"]!.length > TEXT_PREVIEW_BYTES, "long.md is past the Preview's budget"); +const encoder = new TextEncoder(); +const bodies = new Map(Object.entries(files).map(([path, text]) => [path, encoder.encode(text)] as const)); +const bigLines = files["/src/big.ts"]!.split("\n"); +const midLines = files["/src/mid.ts"]!.split("\n"); + +const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-virtual-source-"))); +const evidence = process.env.UI_EVIDENCE_DIR; +if (evidence) mkdirSync(evidence, { recursive: true }); +let browser: Awaited> | undefined; +let server: ReturnType | undefined; +const requests: string[] = []; + +/** Browser-side readers of the virtualizer's shadow DOM, serialized into each `evaluate`. */ +const shadowRows = () => Array.from(document.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]") ?? []); +const scrollRoot = () => document.querySelector(".virtual-source-scroll"); +const rowInView = (row: Element) => { + const view = scrollRoot()?.getBoundingClientRect(); + const rect = row.getBoundingClientRect(); + return view !== undefined && rect.top >= view.top - 1 && rect.bottom <= view.bottom + 1; +}; + +async function open(page: Page, path: string, machine = "local"): Promise { + await page.evaluate(([path, machine]) => window.virtualQA.open(path, machine), [path, machine] as const); + await page.getByRole("dialog").waitFor(); +} +async function close(page: Page): Promise { + await page.getByRole("button", { name: "Close file", exact: true }).click(); + await page.getByRole("dialog").waitFor({ state: "detached" }); +} +async function waitRows(page: Page): Promise { + await page.waitForFunction(() => (document.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0) > 0); +} +interface Mounted { rows: number; first: number; last: number; inView: number; nodes: number } +async function mounted(page: Page): Promise { + return page.evaluate(([shadowRowsSource, rowInViewSource, scrollRootSource]) => { + const scrollRoot = new Function(`return (${scrollRootSource})()`) as () => HTMLElement | null; + const rows = (new Function(`return (${shadowRowsSource})()`) as () => HTMLElement[])(); + const inView = new Function("row", `const scrollRoot = ${scrollRootSource}; return (${rowInViewSource})(row)`) as (row: Element) => boolean; + const lines = rows.map((row) => Number(row.dataset["line"])); + void scrollRoot; + return { rows: rows.length, first: Math.min(...lines), last: Math.max(...lines), inView: rows.filter(inView).length, nodes: document.querySelector("diffs-container")!.shadowRoot!.querySelectorAll("*").length }; + }, [shadowRows.toString(), rowInView.toString(), scrollRoot.toString()] as const); +} +/** The selection a native pre/code of `text` gives: what the browser's serializer makes of the same lines. */ +async function nativeSelection(page: Page, text: string): Promise { + return page.evaluate((text) => { + const pre = document.createElement("pre"); + const code = document.createElement("code"); + code.textContent = text; + pre.append(code); + document.body.append(pre); + const range = document.createRange(); + range.selectNodeContents(code); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + const result = selection.toString(); + selection.removeAllRanges(); + pre.remove(); + return result; + }, text); +} +/** Selects source rows `from`..`to` inside the shadow root, from `startOffset` into the first to `endOffset` into the last (whole rows by default). */ +async function selectRows(page: Page, from: number, to: number, startOffset?: number, endOffset?: number): Promise { + return page.evaluate(([from, to, startOffset, endOffset]) => { + const shadow = document.querySelector("diffs-container")!.shadowRoot!; + const content = (line: number) => { + const row = shadow.querySelector(`[data-line="${line}"]`); + if (row === null) throw new Error(`row ${line} is not mounted`); + return row; + }; + const textNodeAt = (row: HTMLElement, offset: number): [Node, number] => { + const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT); + let seen = 0; + for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) { + const length = (node as Text).data.length; + if (seen + length >= offset) return [node, offset - seen]; + seen += length; + } + throw new Error(`offset ${offset} is past row ${row.dataset["line"]}`); + }; + const range = document.createRange(); + const first = content(from), last = content(to); + if (startOffset === undefined) range.setStart(first, 0); else range.setStart(...textNodeAt(first, startOffset)); + if (endOffset === undefined) range.setEnd(last, last.childNodes.length); else range.setEnd(...textNodeAt(last, endOffset)); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + (window as unknown as { selectedRows: HTMLElement[] }).selectedRows = [first, last]; + return selection.toString(); + }, [from, to, startOffset, endOffset] as const); +} +/** + * Selects rows as `selectRows` does and copies them with a real Cmd+C. Shortly after a mount the + * virtualizer rebuilds the rows it already drew (its first intersection pass resets the render + * range, and a highlight pass renders again), which drops a live selection, so the copy is empty; + * a load (CI's parallel browsers) delays that rebuild past "rows mounted". A copy whose selected + * rows were replaced underneath it is selected and copied again, a few times at most: only a + * detected rebuild retries, so a copy that is wrong over unchanged rows still fails. + */ +async function selectAndCopy(page: Page, from: number, to: number, startOffset?: number, endOffset?: number): Promise<{ selected: string; copied: string }> { + for (let attempt = 1; ; attempt += 1) { + await page.evaluate(() => navigator.clipboard.writeText("")); + const selected = await selectRows(page, from, to, startOffset, endOffset); + await page.keyboard.press("ControlOrMeta+c"); + const [copied, intact] = await page.evaluate(async () => [await navigator.clipboard.readText(), (window as unknown as { selectedRows: HTMLElement[] }).selectedRows.every((row) => row.isConnected)] as const); + if (intact || attempt === 3) return { selected, copied }; + } +} +async function scrollRootTo(page: Page, where: "middle" | "end"): Promise { + await page.evaluate((where) => { + const root = document.querySelector(".virtual-source-scroll")!; + root.scrollTop = where === "end" ? root.scrollHeight : Math.floor(root.scrollHeight / 2); + }, where); +} +/** The state a failed wait is reported with: where the line's row is, if mounted, and what is. */ +async function viewState(page: Page, line: number): Promise { + return page.evaluate((line) => { + const shadow = document.querySelector("diffs-container")?.shadowRoot ?? null; + const root = document.querySelector(".virtual-source-scroll"); + const rows = [...(shadow?.querySelectorAll("[data-line]") ?? [])].map((row) => Number(row.dataset["line"])); + const row = shadow?.querySelector(`[data-line="${line}"]`) ?? null; + const view = root?.getBoundingClientRect(); + const rect = row?.getBoundingClientRect(); + return JSON.stringify({ rows: rows.length, first: Math.min(...rows), last: Math.max(...rows), scrollTop: root?.scrollTop, scrollHeight: root?.scrollHeight, view: view && { width: view.width, height: view.height }, row: rect && view ? { top: rect.top - view.top, height: rect.height } : null }); + }, line); +} +async function settled(page: Page, line: number, step: string): Promise { + try { + await page.waitForFunction((line) => { + const row = document.querySelector("diffs-container")?.shadowRoot?.querySelector(`[data-line="${line}"]`); + const view = document.querySelector(".virtual-source-scroll")?.getBoundingClientRect(); + if (!row || !view) return false; + const rect = row.getBoundingClientRect(); + return rect.top >= view.top - 1 && rect.bottom <= view.bottom + 1; + }, line, { timeout: 15_000 }); + } catch (error) { + throw new Error(`${step}: line ${line} did not come into view: ${await viewState(page, line)}`, { cause: error }); + } + // two frames later it is still there: no pass of the reveal scrolls it away again + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + assert.equal(await page.evaluate((line) => { + const row = document.querySelector("diffs-container")!.shadowRoot!.querySelector(`[data-line="${line}"]`); + const view = document.querySelector(".virtual-source-scroll")!.getBoundingClientRect(); + const rect = row!.getBoundingClientRect(); + return rect.top >= view.top - 1 && rect.bottom <= view.bottom + 1; + }, line), true, `${step}: line ${line} stays in view`); +} +async function noticeTitle(page: Page): Promise { + const notice = page.locator(".file-viewer-meta .file-viewer-notice"); + await notice.waitFor(); + return (await notice.getAttribute("title")) ?? ""; +} +async function assertSyntaxPalette(page: Page, tokenText = "export", variable = "--syntax-keyword"): Promise { + await page.waitForFunction(() => Boolean(document.querySelector("diffs-container")?.shadowRoot?.querySelector("[data-line] span[style*='--herdr-code-token']"))); + const colors = await page.evaluate(({ tokenText, variable }) => { + const shadow = document.querySelector("diffs-container")?.shadowRoot; + const keyword = [...(shadow?.querySelectorAll("[data-line='1'] span[style]") ?? [])] + .find((span) => span.textContent === tokenText); + if (!keyword) throw new Error(`The fixture token ${tokenText} is not colored`); + const probe = document.createElement("span"); + probe.style.color = `var(${variable})`; + document.body.append(probe); + const expected = getComputedStyle(probe).color; + probe.remove(); + return { expected, actual: getComputedStyle(keyword).color }; + }, { tokenText, variable }); + assert.equal(colors.actual, colors.expected, "virtualized syntax uses the current app palette"); +} +async function shot(page: Page, name: string): Promise { + if (evidence) await page.screenshot({ path: join(evidence, `virtual-source-${name}.png`) }); +} + +try { + const repo = join(import.meta.dir, ".."), output = join(root, "dist"); + symlinkSync(join(repo, "node_modules"), join(root, "node_modules"), "dir"); + symlinkSync(join(repo, "scripts/virtual-source-fixture.tsx"), join(root, "fixture.tsx")); + writeFileSync(join(root, "index.html"), '
'); + await viteBuild({ configFile: join(repo, "vite.config.ts"), root, publicDir: false, logLevel: "warn", build: { outDir: output, emptyOutDir: true } }); + const policy = (await serveStatic("/")).headers.get("content-security-policy"); + assert.ok(policy, "build the app before its enforcing CSP regression"); + server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { + const url = new URL(request.url); + const api = /^\/api\/(?:machines\/([^/]+)\/)?fs\/(stat|file)$/.exec(url.pathname); + if (api) { + requests.push(url.pathname); + const path = url.searchParams.get("path") ?? ""; + const body = bodies.get(path); + if (body === undefined) return Response.json({ error: { code: "not_found", message: "No readable file at this path." } }, { status: 404 }); + if (api[2] === "stat") return Response.json({ path, name: path.split("/").pop(), kind: "text", size: body.byteLength, mime: path.endsWith(".md") ? "text/markdown" : "text/plain", modified: "2026-01-01T00:00:00Z" }); + const range = /^bytes=0-(\d+)$/.exec(request.headers.get("range") ?? ""); + if (range && Number(range[1]) < body.byteLength - 1) { + const end = Number(range[1]); + return new Response(body.slice(0, end + 1), { status: 206, headers: { "content-range": `bytes 0-${end}/${body.byteLength}`, "content-type": "text/plain; charset=utf-8" } }); + } + return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8" } }); + } + if (url.pathname === "/favicon.ico") return new Response(null, { status: 204 }); + return new Response(Bun.file(join(output, url.pathname === "/" ? "index.html" : url.pathname.slice(1))), { headers: { "content-security-policy": policy } }); + } }); + const origin = `http://127.0.0.1:${server.port}`; + // the headless shell, not the full browser of CHROME_PATH: its clipboard is in memory, so the real copy below is owned here + browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] }); + assert.match(browser.version(), /^\d+\./, "a browser launched"); + + for (const [width, theme] of [[1440, "dark"], [1440, "light"], [390, "dark"], [390, "light"]] as const) { + const label = `${width}px ${theme}`; + const context = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 900 }, isMobile: width === 390, hasTouch: width === 390, colorScheme: theme }); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await context.addInitScript((theme) => { + localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", theme })); + const record = window as unknown as { longTasks: number[] }; + record.longTasks = []; + new PerformanceObserver((list) => { for (const entry of list.getEntries()) record.longTasks.push(entry.duration); }).observe({ type: "longtask" }); + }, theme); + const page = await context.newPage(); + page.setDefaultTimeout(15_000); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { if (message.type() === "error") errors.push(`console: ${message.text()}`); }); + await page.goto(origin); + + // 120 000+ lines: a window of rows at the top, the middle and the end, never the file + await open(page, "/src/big.ts"); + await waitRows(page); + const top = await mounted(page); + assert.ok(top.rows > 0 && top.rows < 500, `${label}: bounded rows at the top: ${JSON.stringify(top)}`); + assert.equal(top.first, 1, `${label}: the first row is line 1`); + assert.ok(top.inView > 0, `${label}: rows in view at the top`); + assert.equal(await page.locator(".file-viewer-text").count(), 0, `${label}: not an element per line`); + assert.equal(await noticeTitle(page), "Too long to highlight", `${label}: a source past the colors bound says so`); + await page.getByRole("button", { name: "Copy file", exact: true }).waitFor(); + assert.equal(await page.evaluate(() => document.querySelector("diffs-container")!.shadowRoot!.querySelectorAll("[data-line] span[style*='--herdr-code-token']").length), 0, `${label}: plain rows, no colors`); + await shot(page, `top-${width}-${theme}`); + await scrollRootTo(page, "middle"); + await page.waitForFunction((half) => { const rows = [...(document.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]") ?? [])]; return rows.some((row) => Number(row.dataset["line"]) > half); }, Math.floor(BIG_LINES / 2) - 1000); + const middle = await mounted(page); + assert.ok(middle.rows > 0 && middle.rows < 500 && middle.inView > 0 && middle.first > 1000, `${label}: bounded rows in the middle: ${JSON.stringify(middle)}`); + await scrollRootTo(page, "end"); + await page.waitForFunction((last) => [...(document.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]") ?? [])].some((row) => Number(row.dataset["line"]) === last), BIG_LINES + 1); + const end = await mounted(page); + assert.ok(end.rows > 0 && end.rows < 500 && end.inView > 0, `${label}: bounded rows at the end: ${JSON.stringify(end)}`); + assert.equal(end.last, BIG_LINES + 1, `${label}: the last row is the empty line after the final newline`); + await shot(page, `end-${width}-${theme}`); + // the rows take the app's tokens, inside the shadow root + const colors = await page.evaluate(() => { + const probe = document.createElement("span"); + document.body.append(probe); + const resolve = (token: string) => { probe.style.color = `var(${token})`; return getComputedStyle(probe).color; }; + const shadow = document.querySelector("diffs-container")!.shadowRoot!; + const row = shadow.querySelector("[data-line]")!; + const result = { text: resolve("--text"), row: getComputedStyle(row).color, bg: resolve("--bg"), pre: getComputedStyle(shadow.querySelector("pre")!).backgroundColor, host: getComputedStyle(document.querySelector("diffs-container")!).backgroundColor }; + probe.remove(); + return result; + }); + assert.equal(colors.row, colors.text, `${label}: the rows read in the app's text color: ${JSON.stringify(colors)}`); + assert.ok([colors.bg, "rgba(0, 0, 0, 0)"].includes(colors.pre) && [colors.bg, "rgba(0, 0, 0, 0)"].includes(colors.host), `${label}: no code background of the package's own: ${JSON.stringify(colors)}`); + await close(page); + assert.deepEqual(errors, [], `${label}: no errors while opening the big source`); + console.log(`PASS ${label}: 120 000+ lines as a bounded window of rows at the top, the middle and the end (rows ${top.rows}/${middle.rows}/${end.rows})`); + + // a far line: marked, brought to the middle of the view, kept there across wrap and a resize + await open(page, `/src/big.ts:${FAR_LINE}`); + await waitRows(page); + await settled(page, FAR_LINE, `${label} reveal`); + assert.equal(await page.evaluate((line) => document.querySelector("diffs-container")!.shadowRoot!.querySelector(`[data-line="${line}"]`)!.hasAttribute("data-selected-line"), FAR_LINE), true, `${label}: the requested line is marked`); + assert.equal(await page.evaluate((line) => document.querySelector("diffs-container")!.shadowRoot!.querySelectorAll("[data-line][data-selected-line]").length, FAR_LINE), 1, `${label}: one line is marked`); + const far = await mounted(page); + assert.ok(far.rows < 500 && far.first <= FAR_LINE && far.last >= FAR_LINE, `${label}: the far line's rows are a bounded window: ${JSON.stringify(far)}`); + await shot(page, `far-${width}-${theme}`); + await page.evaluate(() => window.virtualQA.settings({ wrapCode: true })); + await settled(page, FAR_LINE, `${label} wrap on`); + assert.ok((await mounted(page)).rows < 500, `${label}: wrapped rows stay bounded`); + await shot(page, `far-wrap-${width}-${theme}`); + const other = width === 1440 ? { width: 390, height: 844 } : { width: 1440, height: 900 }; + await page.setViewportSize(other); + await settled(page, FAR_LINE, `${label} resized to ${other.width}px`); + assert.ok((await mounted(page)).rows < 500, `${label}: rows stay bounded after a resize`); + await shot(page, `far-resized-${width}-${theme}`); + await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); + await settled(page, FAR_LINE, `${label} resized back`); + await page.evaluate(() => window.virtualQA.settings({ wrapCode: false })); + await settled(page, FAR_LINE, `${label} wrap off`); + await close(page); + assert.deepEqual(errors, [], `${label}: no errors around the far line`); + console.log(`PASS ${label}: line ${FAR_LINE} marked and in view, with wrap off and on, after a resize to ${other.width}px and back`); + + // reopened: the rows come back, with a colored source this time + await open(page, `/src/big.ts:${FAR_LINE}`); + await waitRows(page); + await settled(page, FAR_LINE, `${label} reopened`); + await close(page); + await open(page, "/src/mid.ts"); + await waitRows(page); + await page.waitForFunction(() => document.querySelector("diffs-container")!.shadowRoot!.querySelectorAll("[data-line] span[style*='--herdr-code-token']").length > 0, undefined, { timeout: 20_000 }); + assert.equal(await page.locator(".file-viewer-notice").count(), 0, `${label}: a colored source has no notice`); + await assertSyntaxPalette(page); + await shot(page, `colored-${width}-${theme}`); + await close(page); + assert.deepEqual(errors, [], `${label}: no errors on reopen and colors`); + console.log(`PASS ${label}: close and reopen, and a 3 000-line source colored in the worker`); + + const frozen = (await page.evaluate(() => (window as unknown as { longTasks?: number[] }).longTasks ?? [])).filter((duration) => duration >= 1_000); + assert.deepEqual(frozen, [], `${label}: no task held the page for a second`); + await context.close(); + } + + // native selection and a real copy, in the desktop context with the owned clipboard + const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: "dark" }); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await context.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", theme: "dark" }))); + const page = await context.newPage(); + page.setDefaultTimeout(15_000); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { if (message.type() === "error") errors.push(`console: ${message.text()}`); }); + await page.goto(origin); + for (const [path, lines, colored] of [["/src/big.ts", bigLines, false], ["/src/mid.ts", midLines, true]] as const) { + await open(page, path); + await waitRows(page); + if (colored) await page.waitForFunction(() => document.querySelector("diffs-container")!.shadowRoot!.querySelectorAll("[data-line] span[style*='--herdr-code-token']").length > 0, undefined, { timeout: 20_000 }); + // rows 25..35 hold the blank line 30: an exact copy keeps it, and no gutter digit + const interior = lines.slice(24, 35).join("\n"); + const whole = await selectAndCopy(page, 25, 35); + assert.equal(whole.selected, interior, `${path}: a selection over an interior blank line is the source's text`); + assert.equal(whole.copied, interior, `${path}: a real copy of it is exact`); + // a partial selection keeps its exact ends + const partial = `${lines[25]!.slice(3)}\n${lines[26]!}\n${lines[27]!.slice(0, 5)}`; + const part = await selectAndCopy(page, 26, 28, 3, 5); + assert.equal(part.selected, partial, `${path}: a partial selection is exact`); + assert.equal(part.copied, partial, `${path}: a real copy of a partial selection is exact`); + // blank lines at the start and the end of a selection serialize as a native pre/code does + const edges = lines.slice(29, 41).join("\n"); + assert.equal(await selectRows(page, 30, 41), await nativeSelection(page, edges), `${path}: blank first and last lines read as a native pre/code`); + // the trailing blank line and the empty line after the final newline: the source's own tail, + // where a native pre/code would drop the final newline + await scrollRootTo(page, "end"); + await page.waitForFunction((last) => document.querySelector("diffs-container")!.shadowRoot!.querySelector(`[data-line="${last}"]`) !== null, lines.length); + const tail = lines.slice(lines.length - 4).join("\n"); + assert.ok(tail.endsWith("\n\n"), `${path}: the fixture ends with a blank line and its newline`); + const end = await selectAndCopy(page, lines.length - 3, lines.length); + assert.equal(end.selected, tail, `${path}: a selection to the end keeps the trailing blank line and the final newline`); + assert.equal(end.copied, tail, `${path}: a real copy of the tail is the source's tail`); + // a blank row is an empty span given a line break (plain rows) or a bare newline (colored rows) + const heights = await page.evaluate(() => [...document.querySelector("diffs-container")!.shadowRoot!.querySelectorAll("[data-line]")].map((row) => ({ line: Number(row.dataset["line"]), empty: row.textContent === "" || row.textContent === "\n", height: row.getBoundingClientRect().height }))); + const base = heights.find((row) => !row.empty)!.height; + assert.ok(heights.some((row) => row.empty) && heights.filter((row) => row.empty).every((row) => row.height === base), `${path}: blank rows keep the row height ${base}: ${JSON.stringify(heights.filter((row) => row.empty).slice(0, 4))}`); + await close(page); + console.log(`PASS ${path}: native selection and a real copy keep blank, partial and trailing lines, without the gutter`); + } + // the whole source is copied from the text, not from the rows on screen + await open(page, "/src/mid.ts"); + await waitRows(page); + await page.getByRole("button", { name: "Copy file", exact: true }).click(); + await page.getByRole("button", { name: "File copied", exact: true }).waitFor(); + assert.equal(await page.evaluate(() => navigator.clipboard.readText()), files["/src/mid.ts"], "Copy file copies the whole source"); + await close(page); + // with no clipboard, Copy shows the source as one selected text box + await open(page, "/src/mid.ts"); + await waitRows(page); + await page.evaluate(() => { + Object.defineProperty(navigator, "clipboard", { value: { writeText: () => Promise.reject(new Error("no clipboard")) }, configurable: true }); + document.execCommand = () => false; + }); + await page.getByRole("button", { name: "Copy file", exact: true }).click(); + const box = page.locator(".file-viewer-select-source"); + await box.waitFor(); + assert.deepEqual(await box.evaluate((element: HTMLTextAreaElement) => ({ focused: document.activeElement === element, start: element.selectionStart, end: element.selectionEnd, length: element.value.length, readOnly: element.readOnly })), { focused: true, start: 0, end: files["/src/mid.ts"]!.length, length: files["/src/mid.ts"]!.length, readOnly: true }, "the whole source is selected in a readonly box"); + assert.equal(await page.locator("diffs-container").count(), 0, "the rows give way to the box"); + await close(page); + assert.deepEqual(errors, [], "no errors around selection and copy"); + console.log("PASS Copy file copies the whole source; without a clipboard it selects the source in a readonly box"); + + // cuts and notices + await open(page, "/src/lines.txt"); + await waitRows(page); + assert.equal(await noticeTitle(page), `Showing the first ${SOURCE_PREVIEW_LINES.toLocaleString("en-US")} lines of this file. Download it to read the rest.`, "a file past the line cap says so"); + assert.equal(await page.getByRole("button", { name: "Copy file", exact: true }).count(), 0, "a cut file has no Copy"); + await scrollRootTo(page, "end"); + await page.waitForFunction((last) => document.querySelector("diffs-container")!.shadowRoot!.querySelector(`[data-line="${last}"]`) !== null, SOURCE_PREVIEW_LINES); + // the cut prefix ends on its last complete line: no row for the line after its final newline + assert.equal((await mounted(page)).last, SOURCE_PREVIEW_LINES, "the rows end at the cap, without an artificial row after it"); + await close(page); + // the line at the cap is shown and marked; the one just past it is unseen, so it is explained, not marked + await open(page, `/src/lines.txt:${SOURCE_PREVIEW_LINES}`); + await waitRows(page); + await settled(page, SOURCE_PREVIEW_LINES, "the line at the cap"); + assert.equal(await page.evaluate((line) => document.querySelector("diffs-container")!.shadowRoot!.querySelector(`[data-line="${line}"]`)!.hasAttribute("data-selected-line"), SOURCE_PREVIEW_LINES), true, "the line at the cap is marked"); + await close(page); + await open(page, `/src/lines.txt:${SOURCE_PREVIEW_LINES + 1}`); + await waitRows(page); + const pastCap = await noticeTitle(page); + assert.ok(pastCap.includes(`Line ${SOURCE_PREVIEW_LINES + 1} is past the part of this file shown here.`), `the line past the cap is unseen: ${pastCap}`); + assert.equal(await page.evaluate(() => document.querySelector("diffs-container")!.shadowRoot!.querySelectorAll("[data-line][data-selected-line]").length), 0, "no row is marked for the line past the cap"); + assert.equal(await page.evaluate((line) => document.querySelector("diffs-container")!.shadowRoot!.querySelector(`[data-line="${line}"]`), SOURCE_PREVIEW_LINES + 1), null, "no row carries the number past the cap"); + await close(page); + await open(page, "/src/huge.log"); + await waitRows(page); + assert.equal(await noticeTitle(page), "Showing the first 8 MB of 9 MB.", "a file past the byte budget says so"); + assert.equal(await page.locator(".file-viewer-meta .file-viewer-notice span").first().innerText(), "8 MB of 9 MB", "the header's size is the part shown"); + assert.equal(await page.getByRole("button", { name: "Copy file", exact: true }).count(), 0, "a cut file has no Copy"); + // the complete lines within the first 8 MiB (ASCII, so bytes are characters): the reader cuts after the last newline + const lastComplete = files["/src/huge.log"]!.slice(0, 8 * 1024 * 1024).split("\n").length - 1; + await scrollRootTo(page, "end"); + await page.waitForFunction((last) => document.querySelector("diffs-container")!.shadowRoot!.querySelector(`[data-line="${last}"]`) !== null, lastComplete); + const lastRow = await page.evaluate((last) => ({ last: Math.max(...[...document.querySelector("diffs-container")!.shadowRoot!.querySelectorAll("[data-line]")].map((row) => Number(row.dataset["line"]))), text: document.querySelector("diffs-container")!.shadowRoot!.querySelector(`[data-line="${last}"]`)!.textContent ?? "" }), lastComplete); + assert.ok(lastRow.last === lastComplete && lastRow.text.length > 0, `a byte cut ends on its last complete line ${lastComplete}: ${JSON.stringify(lastRow).slice(0, 80)}`); + await close(page); + await open(page, "/src/small.ts"); + await page.locator(".file-viewer-text .hl-line").first().waitFor(); + assert.equal(await page.locator(".file-viewer-text .hl-line").count(), 51, "a small source stays an element per line"); + assert.equal(await page.locator("diffs-container").count(), 0, "and is not virtualized"); + await close(page); + assert.deepEqual(errors, [], "no errors around the cuts"); + console.log("PASS a file past the line cap and one past the byte budget say so truthfully and end on their last complete line, the cap is marked and the line past it explained; a small source is unchanged"); + + // settings: theme + await open(page, "/src/mid.ts"); + await waitRows(page); + await page.waitForFunction(() => document.querySelector("diffs-container")!.shadowRoot!.querySelectorAll("[data-line] span[style*='--herdr-code-token']").length > 0, undefined, { timeout: 20_000 }); + await page.evaluate(() => window.virtualQA.settings({ theme: "light" })); + await page.locator(".virtual-source[data-colored]").waitFor(); + await waitRows(page); + await page.waitForFunction(() => document.documentElement.dataset["theme"] === "light"); + const light = await page.evaluate(() => { + const probe = document.createElement("span"); + document.body.append(probe); + probe.style.color = "var(--text)"; + const text = getComputedStyle(probe).color; + probe.remove(); + return { text, row: getComputedStyle(document.querySelector("diffs-container")!.shadowRoot!.querySelector("[data-line]")!).color }; + }); + assert.equal(light.row, light.text, `the rows follow the light theme's text color: ${JSON.stringify(light)}`); + await assertSyntaxPalette(page); + await close(page); + await page.evaluate(() => window.virtualQA.settings({ theme: "dark" })); + console.log("PASS Settings: the theme reaches the rows"); + + // Markdown keeps its Preview budget; its source is virtualized past the line limit + await open(page, "/docs/long.md"); + await page.locator(".file-viewer-markdown .markdown-h1").first().waitFor(); + assert.equal(await noticeTitle(page), `Showing the first 256 KB of ${formatBytes(bodies.get("/docs/long.md")!.byteLength)}.`, "a Markdown file past the Preview's budget keeps it"); + await page.getByRole("button", { name: "Show source", exact: true }).click(); + await waitRows(page); + assert.ok((await mounted(page)).rows < 500, "the Markdown source is virtualized"); + await close(page); + await open(page, "/docs/notes.md"); + await page.locator(".file-viewer-markdown").waitFor(); + await page.getByRole("button", { name: "Show source", exact: true }).click(); + await waitRows(page); + await page.waitForFunction(() => document.querySelector("diffs-container")!.shadowRoot!.querySelectorAll("[data-line] span[style*='--herdr-code-token']").length > 0, undefined, { timeout: 20_000 }); + await assertSyntaxPalette(page, "## Section", "--syntax-meta"); + await close(page); + console.log("PASS Markdown keeps the Preview's budget, and its source is virtualized and colored"); + + // a remote PC's file reads through its bridge + requests.length = 0; + await open(page, "/src/mid.ts", "remote-fixture"); + await waitRows(page); + assert.ok(requests.length >= 2 && requests.every((path) => path.startsWith("/api/machines/remote-fixture/fs/")), `remote requests: ${requests.join(", ")}`); + await close(page); + assert.deepEqual(errors, [], "no errors at the end"); + await context.close(); + console.log("PASS a remote PC's source reads through its bridge"); + console.log("virtual source: browser OK (bounded rows, far line, wrap and resize, native selection and real copy, whole copy and its fallback, cuts, settings, Markdown, remote)"); +} finally { + await browser?.close(); server?.stop(true); rmSync(root, { recursive: true, force: true }); +} diff --git a/scripts/virtual-source-fixture.tsx b/scripts/virtual-source-fixture.tsx new file mode 100644 index 000000000..42bb773da --- /dev/null +++ b/scripts/virtual-source-fixture.tsx @@ -0,0 +1,18 @@ +import React, { useState } from "react"; +import { createRoot } from "react-dom/client"; +import "../src/fonts/fonts.css"; +import "../src/styles.css"; +import { FileViewer } from "../src/components/FileViewer.tsx"; +import { MachineContext } from "../src/lib/machineContext.tsx"; +import { SettingsProvider, useSettings, type Settings } from "../src/lib/settings.ts"; +declare global { interface Window { virtualQA: { open: (path: string, machine?: string) => void; close: () => void; settings: (patch: Partial) => void } } } +function Fixture() { + const [target, setTarget] = useState<{ path: string; machine: string } | null>(null); + const { update } = useSettings(); + window.virtualQA = { open: (path, machine = "local") => setTarget({ path, machine }), close: () => setTarget(null), settings: update }; + return
+ + {target && setTarget(null)} />} +
; +} +createRoot(document.getElementById("root")!).render(); diff --git a/scripts/windows-install.test.ps1 b/scripts/windows-install.test.ps1 index 9bea8530d..06f8f6152 100644 --- a/scripts/windows-install.test.ps1 +++ b/scripts/windows-install.test.ps1 @@ -6,7 +6,8 @@ $originalLocalAppData = $env:LOCALAPPDATA $originalUserProfile = $env:USERPROFILE $state = @{ installed = $false; installs = 0; uninstalls = 0; ref = ''; running = $true; started = $false; failInstall = $false; bunVersion = '1.4.2'; failBun = $false; windowsRelease = $true } # gh is a stand-in .cmd, first on PATH: the installer runs gh as a process, and no real star is read or given. -# mode.txt beside it is what it answers: starred, not, refuses (the star itself), upstream, signed-out or hangs. +# mode.txt beside it is what it answers: starred, not, refuses (the star itself), upstream, signed-out, hangs or +# lingering (it answers, and leaves a child holding its output; child.txt gets that child's PID). $star = @{ asked = 0; answer = ''; failAsk = $false } $ghStandIn = Join-Path ([IO.Path]::GetTempPath()) "herdr-installer-gh-$PID" # A real directory: the installer reads the installed copy to tell whether it can run on Windows. @@ -73,6 +74,7 @@ if "%mode%"=="starred" goto starred if "%mode%"=="hangs" goto hangs if "%mode%"=="signed-out" goto signedout if "%mode%"=="upstream" goto upstream +if "%mode%"=="lingering" goto lingering echo HTTP/2.0 404 Not Found echo gh: Not Found (HTTP 404) 1>&2 exit /b 1 @@ -85,6 +87,10 @@ exit /b 0 :signedout echo To get started with GitHub CLI, please run: gh auth login 1>&2 exit /b 4 +:lingering +powershell -NoProfile -Command "(Start-Process ping -ArgumentList '-n','47','127.0.0.1' -NoNewWindow -PassThru).Id | Set-Content -Encoding Ascii '%~dp0child.txt'" +echo HTTP/2.0 404 Not Found +exit /b 1 :upstream echo HTTP/2.0 500 Internal Server Error echo gh: upstream answered HTTP 404 (HTTP 500) 1>&2 @@ -206,6 +212,120 @@ exit /b 1 & $firstInstall 'signed-out' | Out-Null Write-Host 'PASS star: silent when starred or on a rerun, a link without gh, an answer or a terminal, asked once at a terminal and given only on yes' + # A gh that leaves a child holding its output: the call ends on its deadline and the child ends with it. + # Invoke-GhApi alone, from the installer's own text, so that only what it starts is looked at. + $parsed = [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $installer).Path, [ref]$null, [ref]$null) + $ghApi = $parsed.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Invoke-GhApi' }, $true) + Assert ($null -ne $ghApi) 'The installer must define Invoke-GhApi' + Invoke-Expression $ghApi.Extent.Text + Set-Content -LiteralPath (Join-Path $ghStandIn 'mode.txt') -Encoding Ascii -Value 'lingering' + $childFile = Join-Path $ghStandIn 'child.txt' + Remove-Item -LiteralPath $childFile -Force -ErrorAction SilentlyContinue + $clock = [Diagnostics.Stopwatch]::StartNew() + $answer = Invoke-GhApi 'user/starred/devswha/herdr-web-ui' + $elapsed = $clock.Elapsed.TotalSeconds + $childPid = [int](Get-Content -LiteralPath $childFile -ErrorAction SilentlyContinue | Select-Object -First 1) + Assert ($childPid -gt 0) 'control: the lingering stand-in must have started its child and named its PID' + Assert ($null -eq $answer -and $elapsed -lt 15) "control: a gh whose output stays open must be left after its deadline, with no answer: $elapsed s" + $child = Get-Process -Id $childPid -ErrorAction SilentlyContinue | Where-Object ProcessName -eq 'PING' + $left = $child -and -not $child.WaitForExit(5000) + if ($left) { Stop-Process -Id $childPid -Force -ErrorAction SilentlyContinue } + Assert (-not $left) "regression (fails on main): the child gh left holding its output (PID $childPid) must not outlive the call" + Write-Host "PASS gh deadline: a child of gh that holds its output ends with the call (PID $childPid, $([math]::Round($elapsed, 1)) s)" + + # A native shim starts its output-holding child as its first action, without a PowerShell startup delay. + # At the assignment boundary, force the old runnable launcher to reach child-ready before proceeding. + # The fixed launcher must still be suspended there. The same assertion is run against both launches. + $immediate = Join-Path $ghStandIn 'immediate shim' + New-Item -ItemType Directory -Path $immediate | Out-Null + Add-Type -OutputAssembly (Join-Path $immediate 'gh.exe') -OutputType ConsoleApplication -TypeDefinition @' +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +public static class ImmediateGh { + public static void Main() { + Process child = Process.Start(new ProcessStartInfo("ping.exe", "-n 47 127.0.0.1") { UseShellExecute = false }); + File.WriteAllText(Environment.GetEnvironmentVariable("HERDR_TEST_GH_CHILD"), child.Id.ToString()); + using (EventWaitHandle ready = EventWaitHandle.OpenExisting(Environment.GetEnvironmentVariable("HERDR_TEST_GH_READY"))) ready.Set(); + Console.WriteLine("HTTP/2.0 404 Not Found"); + Console.Error.WriteLine("gh: Not Found (HTTP 404)"); + Environment.ExitCode = 1; + } +} +'@ + $assignment = '[HerdrWebUi.GhJob]::Assign($job, $process.Handle)' + $orderedSource = $ghApi.Extent.Text.Replace("`r`n", "`n").Replace($assignment, '(Assign-TestGhJob $job $process)') + Assert ($orderedSource -ne $ghApi.Extent.Text) 'control: interpose at the real job assignment boundary' + function Assign-TestGhJob($Job, $Process) { + if ($Process -is [Diagnostics.Process]) { + Assert ($ready.WaitOne(5000)) 'control: the runnable shim must reach child-ready before assignment' + } + Assert (-not $ready.WaitOne(0)) 'regression: shim ran before job assignment' + [HerdrWebUi.GhJob]::Assign($Job, $Process.Handle) + } + $suspendedLaunch = @' + if ($job -ne [IntPtr]::Zero) { + $process = [HerdrWebUi.GhJob]::StartSuspended($start.FileName, $start.Arguments) + if (-not (Assign-TestGhJob $job $process)) { + $null = [HerdrWebUi.GhJob]::CloseHandle($job); $job = [IntPtr]::Zero + } + $process.Resume() + } else { + $process = [Diagnostics.Process]::Start($start) + } +'@ + $runnableLaunch = @' + $process = [Diagnostics.Process]::Start($start) + if ($job -ne [IntPtr]::Zero -and -not (Assign-TestGhJob $job $process)) { + $null = [HerdrWebUi.GhJob]::CloseHandle($job); $job = [IntPtr]::Zero + } +'@ + # Scratch-source reversion of exactly the pre-fix runnable Start/Assign ordering; no repository file changes. + $runnableSource = $orderedSource.Replace($suspendedLaunch.Replace("`r`n", "`n"), $runnableLaunch) + $path = $env:PATH + $oldReady = $env:HERDR_TEST_GH_READY; $oldChild = $env:HERDR_TEST_GH_CHILD + try { + $env:PATH = "$immediate;$path" + foreach ($case in @( + @{ name = 'reverted'; source = $runnableSource }, + @{ name = 'fixed'; source = $orderedSource } + )) { + $env:HERDR_TEST_GH_READY = "Local\herdr-gh-ready-$([Guid]::NewGuid())" + $env:HERDR_TEST_GH_CHILD = Join-Path $immediate 'child.txt' + Remove-Item -LiteralPath $env:HERDR_TEST_GH_CHILD -Force -ErrorAction SilentlyContinue + $ready = New-Object Threading.EventWaitHandle($false, [Threading.EventResetMode]::ManualReset, $env:HERDR_TEST_GH_READY) + $failure = '' + try { + Invoke-Expression $case.source + $clock = [Diagnostics.Stopwatch]::StartNew() + $answer = Invoke-GhApi 'user/starred/devswha/herdr-web-ui' + Assert ($null -eq $answer -and $clock.Elapsed.TotalSeconds -lt 15) 'regression: immediate child must not keep the call waiting past its deadline' + Assert ($ready.WaitOne(0)) 'control: the resumed shim must have started its child' + $childPid = [int](Get-Content -LiteralPath $env:HERDR_TEST_GH_CHILD) + $child = Get-Process -Id $childPid -ErrorAction SilentlyContinue + Assert (-not $child -or $child.WaitForExit(5000)) 'regression: immediate child must end with the assigned job' + } catch { $failure = $_.Exception.Message } finally { + $childPid = [int](Get-Content -LiteralPath $env:HERDR_TEST_GH_CHILD -ErrorAction SilentlyContinue | Select-Object -First 1) + if ($childPid -gt 0) { + Get-Process -Id $childPid -ErrorAction SilentlyContinue | Where-Object ProcessName -eq 'PING' | + Stop-Process -Force -ErrorAction SilentlyContinue + } + $ready.Dispose() + } + if ($case.name -eq 'reverted') { + Assert ($failure -eq 'regression: shim ran before job assignment') "The reverted launch must fail the ordering assertion, not another error: $failure" + Write-Host "PASS gh ordering negative control: reverted launch 0 pass, 1 fail ($failure)" + } else { + Assert (-not $failure) "The suspended launch must pass the same ordering and child cleanup test: $failure" + Write-Host 'PASS gh ordering: suspended launch 1 pass, 0 fail; immediate child ends with its job' + } + } + } finally { + $env:PATH = $path + $env:HERDR_TEST_GH_READY = $oldReady; $env:HERDR_TEST_GH_CHILD = $oldChild + } + # A PC without herdr: its installer is a stand-in .cmd, and nothing of this PC's own herdr is in reach. $scratch = Join-Path ([IO.Path]::GetTempPath()) "herdr-installer-test-$PID" New-Item -ItemType Directory -Force -Path $scratch | Out-Null diff --git a/scripts/windows-lens-browser-qa.ts b/scripts/windows-lens-browser-qa.ts index e97b0da32..24134c533 100644 --- a/scripts/windows-lens-browser-qa.ts +++ b/scripts/windows-lens-browser-qa.ts @@ -11,6 +11,7 @@ import { join } from "node:path"; import { chromium, type Page } from "playwright-core"; import { createServer } from "../server/index.ts"; import { workspaceCreate, workspaceClose } from "../server/herdr/client.ts"; +import { chromePath } from "./chrome-path.ts"; const root = mkdtempSync(join(tmpdir(), "herdr-web-ui-winlens-")); const evidence = process.env["UI_EVIDENCE_DIR"]; @@ -76,7 +77,7 @@ try { const origin = `http://127.0.0.1:${server.port}`; const health = await (await fetch(`${origin}/api/health`)).json() as { herdr: { terminal_attach?: boolean; terminal_mirror?: boolean } }; assert.deepEqual([health.herdr.terminal_attach, health.herdr.terminal_mirror], [false, true]); - browser = await chromium.launch({ executablePath: process.env["CHROME_PATH"] ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); + browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const page = await context.newPage(); const errors: string[] = []; diff --git a/scripts/windows-plugin.test.ts b/scripts/windows-plugin.test.ts index 054eae0e0..58c1c6e73 100644 --- a/scripts/windows-plugin.test.ts +++ b/scripts/windows-plugin.test.ts @@ -7,6 +7,71 @@ import { socketAddress } from "../server/herdr/client.ts"; import { windowsProcessTable } from "../server/windows-processes.ts"; import { updateStateDir } from "../server/update-state.ts"; +it("waits for every snapshotted Windows tree member after termination returns", async () => { + const root = join(import.meta.dir, ".."); + const scratch = mkdtempSync(join(tmpdir(), "herdr windows stop tree ")); + const state = join(scratch, "plugin-state"); + mkdirSync(state); + const rootPid = process.pid; + const childPid = process.pid + 100_000_000; + const previousRoot = process.env["HERDR_PLUGIN_ROOT"]; + const previousStateDir = process.env["HERDR_PLUGIN_STATE_DIR"]; + const previousConfigDir = process.env["HERDR_PLUGIN_CONFIG_DIR"]; + process.env["HERDR_PLUGIN_ROOT"] = root; + process.env["HERDR_PLUGIN_STATE_DIR"] = state; + process.env["HERDR_PLUGIN_CONFIG_DIR"] = join(scratch, "config"); + mkdirSync(join(scratch, "config")); + writeFileSync(join(state, "server.pid"), `${rootPid}\n`); + let terminated = false; + let childAlive = true; + let polls = 0; + try { + const { stop } = await import("./plugin.ts"); + const rootRow = { + pid: rootPid, + parent: 0, + path: process.execPath, + commandLine: `"${process.execPath}" "${join(root, "server", "managed.ts")}"`, + started: 1, + }; + const childRow = { + pid: childPid, + parent: rootPid, + path: process.execPath, + commandLine: `"${process.execPath}" -e child`, + started: 2, + }; + const result = await stop({ + isWindows: true, + isAlive: pid => pid === rootPid ? !terminated : pid === childPid && childAlive, + processTable: async () => { + if (!terminated) return [rootRow, childRow]; + polls += 1; + if (polls === 4) childAlive = false; + return childAlive ? [childRow] : []; + }, + terminateTree: pid => { + expect(pid, "control: termination targets the recorded root process").toBe(rootPid); + terminated = true; + return { status: 0, error: undefined, stderr: "" }; + }, + }); + expect(result, "control: stop returns success after the original tree is gone").toBe(0); + expect(childAlive, "regression (fails on main): stop must wait until every snapshotted member reports dead").toBe(false); + expect(polls, "control: process liveness was rechecked through the delayed exit").toBe(4); + } finally { + for (const [key, value] of [ + ["HERDR_PLUGIN_ROOT", previousRoot], + ["HERDR_PLUGIN_STATE_DIR", previousStateDir], + ["HERDR_PLUGIN_CONFIG_DIR", previousConfigDir], + ] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(scratch, { recursive: true, force: true }); + } +}, 15_000); + it.skipIf(process.platform !== "win32")("starts after its launcher exits, stops its owned tree and restarts on Windows", async () => { const root = join(import.meta.dir, ".."); const scratch = mkdtempSync(join(tmpdir(), "herdr windows plugin ")); @@ -89,7 +154,17 @@ it.skipIf(process.platform !== "win32")("starts after its launcher exits, stops expect((await fetch(`http://127.0.0.1:${port}/api/health`)).ok).toBe(true); } finally { utimesSync(join(state, "server.pid"), record.atime, record.mtime); } await run("stop"); - for (const member of owned) expect(() => process.kill(member, 0)).toThrow(); + const deadline = Date.now() + 10_000; + const stillOwned = () => [...owned].filter(member => { + try { process.kill(member, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } + }); + let remaining = stillOwned(); + while (remaining.length > 0 && Date.now() < deadline) { + await Bun.sleep(50); + remaining = stillOwned(); + } + expect(remaining, "control: wait up to ten seconds for the Windows-owned process tree to disappear").toEqual([]); expect(existsSync(join(state, "server.pid"))).toBe(false); await run("start"); // forced Windows shutdown leaves a stale lock; restart must recover it expect((await fetch(`http://127.0.0.1:${port}/api/health`)).ok).toBe(true); diff --git a/scripts/workspace-rows-regression.ts b/scripts/workspace-rows-regression.ts index 539094895..94d6bbf94 100644 --- a/scripts/workspace-rows-regression.ts +++ b/scripts/workspace-rows-regression.ts @@ -225,8 +225,8 @@ try { "right-clicking another workspace keeps the active pane"); await page.keyboard.press("Escape"); await workspaceMenu.waitFor({ state: "detached" }); - // Renaming a pane is still available on that pane's own menu. - await page.locator(`[data-layout-pane=${JSON.stringify(split.pane.pane_id)}] .pane-frame-menu`).click(); + // Renaming a pane is still available on that pane's own menu (a mouse opens it by right-click). + await page.locator(`[data-layout-pane=${JSON.stringify(split.pane.pane_id)}] .pane-frame-head`).click({ button: "right" }); await page.getByRole("menuitem", { name: "Rename pane", exact: true }).click(); const paneRename = page.getByRole("dialog", { name: "Rename pane", exact: true }); const paneName = paneRename.getByRole("textbox", { name: "Pane name", exact: true }); diff --git a/scripts/workspace-touch-reorder-demo-regression.ts b/scripts/workspace-touch-reorder-demo-regression.ts index b4dfe5479..8c46ef186 100644 --- a/scripts/workspace-touch-reorder-demo-regression.ts +++ b/scripts/workspace-touch-reorder-demo-regression.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { chromium, type CDPSession, type Page } from "playwright-core"; import { buildDemoApp } from "./demo-build.ts"; +import { chromePath } from "./chrome-path.ts"; // A finger reorders the sidebar's workspaces (src/lib/touchReorder.ts), on the unmodified app over // the demo's fixture transport at a phone's size with touch: a long press lifts a row, a drag drops @@ -55,7 +56,7 @@ try { } }); try { - const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); + const browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); try { const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: "en-US" }); await context.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" }))); diff --git a/server/AGENTS.md b/server/AGENTS.md index c03dd2532..7149677fa 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -1,89 +1,12 @@ # server/ -Bun backend: HTTP API + WebSocket fan-out over herdr's unix-socket API, plus the PTY attach sidecar. The attachment lifecycle and transport quirks live here and nowhere else. - -## ANTI-PATTERNS -- Never let an attachment outlive its last client. Cleanup paths: `detach`/`close` for members (`detach()` closes on an empty client set), `releaseUnclaimed` for an attach cancelled mid-creation, `onExit` for a pty that died, `stop()` for everything. -- Never `attachments.set` outside `spawnAttachment`, and never await between that `set` and `new PtySession(...)`: the record's `pty` is a placeholder until then. -- Never add the client to `attachment.clients` before checking `client.data.attached` still holds the same claim after the await. Detach followed by reattach installs a new claim. -- Always resize the pty from the geometry of an INTERACT client's attach (`attach` handler calls `resizePty` when `mode === "interact"`); an observe attach never resizes, and a mirrored pane keeps the pane's own grid (`pane-geometry` with `fixed: true`). Never spawn `herdr terminal attach` without cols/rows. -- Answer in-band: handler failures are sent as `{type:"error"}` frames. The only deliberate closes are 4008 (output stall/overflow), 1008 (device revoked) and 1012 (relay/PC gone). -- Keep `herdr/` free of HTTP concepts — it speaks only herdr's socket protocol; HTTP shaping belongs in index.ts. -- NEVER refresh an OAuth token in usage.ts. Claude, Codex, Cursor and Grok rotate refresh tokens, so a refresh their CLI did not make signs that CLI out; an expired sign-in is reported as `expired` and left for the CLI to renew. Nothing is read in the background, and a provider that answered 429 is not asked again before it said to. -- From the first paired device on, the gate is closed to strangers and stays closed when every device is revoked — revoking must never reopen the LAN — while this PC itself gets in without pairing (once a token is set, every client needs it, except a paired device, whose cookie is accepted before the token is asked for). A devices.json that will not parse is not a first installation: keep it for recovery and let the local/token/Tailscale routes serve the owner. -- Only proof says a herdr is gone (herdr-marker.ts): a refused connection, or on Windows a marker whose pid is gone, is not herdr, or started at another time. A slow daemon, an unreadable marker or an unreadable process table is never "stale": on Windows herdr.sock is a `pid:start` marker file a killed daemon leaves behind, and the pid alone says nothing, because Windows hands a dead process's pid to the next one. -- The user's own API keys (voice.ts, usage.ts) are read on the server and sent upstream only: no answer or error message carries one. voice.ts scrubs the key out of a provider's failure text; usage.ts answers with problem codes and drops a failed answer's body, but logs an exception's message as it is, so never put a credential into an error thrown there. -- Cache-Control is decided per file in static.ts: only Vite's fingerprinted `/assets/` may be pinned for a year. `sw.js`, the web manifest and the shell served for `/` and unknown paths must revalidate on every load (a direct `/index.html` request gets the one-day default), or the installed PWA is pinned to a build the user can no longer get rid of. - -## CONVENTIONS -- `attachments: Map` holds `{ pty, clients, cols, rows, replay }`. `cols`/`rows` are the pty's current grid: `resizePty` updates them and broadcasts `pane-geometry` to the pane's clients whenever they change. `replay` is a bounded 256KB tail sent to clients that attach late; `pty-exit` broadcasts first, then closes the attachment. -- Read-only live views (`watch.ts`, herdr >= 0.7.2) run `herdr terminal session observe --cols C --rows R` with plain `Bun.spawn`, never the PTY sidecar. A writable attach locks the pane at the browser's size; the observer does not take resize ownership (measured on 0.9.3: the TUI pane stayed 44x37 while observed at 150x50), so a tab out of use detaches before watching. -- `SocketData.watches` owns one observer per pane and connection, independent of attachments, claims, ACK windows and pane geometry. Repeat watch replaces it; unwatch kills it silently; natural close/exit sends `watch-end` only for the current observer. Kill and clear watches on connection close, device revocation, stalled-consumer cleanup and `stop()`; read-only devices and observe-role connections may watch. -- `createServer` starts `startStatusCollector` (collector.ts): per-pane `agent_status_changed` for EVERY pane in the snapshot plus a global pane.created/pane.closed/pane.exited subscription. Every stream reopens itself when herdr closes it (events_lost, restart) with 250 ms→5 s backoff; a lifecycle stream that comes back reconciles, and a lost status stream resyncs from a snapshot taken after the new subscription started. Its callbacks `broadcastAll` `pane-status` / `pane-exited` / `session-changed` to every connected client — `pane-status` no longer depends on attachment. The client set is tracked in a `clients` Set (open/close). -- Sidecar protocol (pty-host.mjs): argv ` [args...]`; stdin newline-JSON frames — `{"t":"i","d":""}` writes, `{"t":"r","c":cols,"r":rows}` resizes; stdout is raw pty bytes unmodified; exit mirrors the child's code; sidecar clamps cols/rows to 1..1000. -- Output control adds `{"t":"p","paused":true|false}`. Parent pause and stdout backpressure are independent; both must clear to resume. Kill resumes the reader to observe EOF and has a bounded forced cleanup. PtySession awaits stdout EOF before reporting process exit. -- ACK-capable attaches carry `flow_control:"ack"`; `pty-data.flow` identifies the subscription and cumulative UTF-8 byte offset. `pty-ack` releases only already-sent credit in that subscription. Pause at 256 KiB, resume at <=64 KiB, close consumers blocked for 2s with code 4008; 1 MiB hard application/transport budgets. `-1` from Bun send is already queued, never retransmit it. Legacy clients have the transport cap only. See docs/terminal-flow-control.md and server/output.contract.test.ts. -- `HerdrError.code` → HTTP status (http.ts `errorResponse`): `connect_failed`/`timeout` → 502; other HerdrError codes → 404; anything else → 500 `internal_error`. -- Auth: auth.ts `isAuthenticated` accepts the `herdr_web_token` cookie or an Authorization Bearer (length-safe compare); access.ts `decideAccess` (token → device cookie → Tailscale owner, by login header, or with no login header on a tailnet one login provably owns when HERDR_WEB_TAILSCALE_SERVE_ONLY declares serve the only ingress → loopback local → pairing) runs for /ws and every /api/* route except /api/health, /api/auth and /api/devices/pair; static files stay open. -- An unauthenticated /ws upgrade is refused with a plain-text 401 `unauthorized` (the WS client never parses a body); every other refusal is the JSON envelope. -- `terminalInfoFor` resolves pane_id → `{ terminalId, rect }` from one fresh `session.snapshot` (rect = the pane's `layouts[].panes[].rect`, null when absent); missing pane → `pane_not_found`, pane without terminal → `no_terminal`. -- The layout `rect` is NOT the terminal's current size (measured on herdr 0.9.3): it stayed 120x40 while a `terminal attach` sized the pane 100x30, then 140x40, and the pane kept 140x40 after that attach left. Only the attached client's size is real; nothing in the API tells another bridge's size. An attachment started again (held retry, live handoff) therefore reuses its own cached `cols`/`rows` (#363 review). While a `terminal attach` is connected herdr holds the pane at that attach's size, and its own TUI cannot resize it; the TUI gets its own size back only when the attach ends (measured on 0.9.3 with a TUI client: a split pane 44x37, 150x50 while attached, 44x37 once the attach ended). The rect is not that size either: it includes the pane's border (a 47x39 rect was a 44x37 pty). -- Attachment creation: `ensureAttachment` returns the existing record, else joins the in-flight creation in `pendingAttachments` (cleared in `finally`), else starts `spawnAttachment`. An observe-first creation spawns at the pane's rect (fallback 80x24), never the observer's grid; an interact-first one at the client's grid. A mirrored pane always takes the pane's rect. -- The `attach` handler records the pane in `client.data.attached` BEFORE awaiting the creation, so a `detach` (the client's pane switch sends detach-old then attach-new) or `close` landing mid-creation cancels it; on resume a cancelled attach calls `releaseUnclaimed`, which closes the record only if it has no clients AND no other client still has the pane in `attached` (a joiner of the same creation that has not resumed yet). A failed creation removes the pane from `attached` again. -- `closeAttachment` removes the pane from every member's `attached`: after a pty exit the members sit on "terminal ended" and a stale entry would read as a live claim in `releaseUnclaimed`. -- An attach exit (not a read race, `attach_held` or a retired refusal) is not yet the pane's end: `ended` looks the pane up every 100 ms for `ATTACH_RELOOKUP_FOR_MS` (5 s). A new `terminal_id` (herdr live handoff) starts a new attach for the same clients; a gone pane gets `pty-exit` at once; the same terminal or an unreachable herdr gets it at the deadline. The timer is `attachment.relookup`, cleared in `closeAttachment` (#370). -- SocketData holds `attached`, explicit `conversationInterests`, `mode`, per-pane `output` windows and a `closing` flag. Conversation monitoring requires an attached claim and enabled interest; the last interested client releases it even while terminal-only clients remain attached. An observe connection's `input`/`keys`/`resize` answer `{type:"error", code:"read_only"}`, and its `attach` never resizes the pty — it receives the current `pane-geometry` and adopts it. Observers can acknowledge output and subscribe to conversation invalidations. -- Native conversation invalidations stat the resolved transcript, or Devin/OpenCode's SQLite database and its `-wal` when present; session changes invalidate even when the database path stays the same, and REST remains authoritative. -- WS `keys` messages go through herdr RPC (`pane.send_keys`), not the pty; `input` goes straight into the pty, or to `pane.send_text` on a mirrored pane. A key for an attached pane waits `KEY_SETTLE_MS` after the pane's last pty keystroke, and `TYPED_SETTLE_MS` when that keystroke ended in an ESC (herdr holds a lone ESC on the pty ~150ms), so it never overtakes typing; typing that arrives while a key waits queues behind it. -- `SocketData.attached` maps pane IDs to opaque claims that last until detach or terminal end; repeated attaches preserve the current claim. Queued terminal `keys` retain that sender claim, attachment and pty. Recheck their identity, sender membership and readiness before sending, so detach (even followed by reattach) or terminal replacement cancels stale chords. A cancelled chord or text is answered `input_failed`, never `input_not_ready`: the client drops the pane's readiness on that code, and the attach it holds by now was already told `input-ready`. Unattached RPC keys keep their existing path. -- Pending chat messages are bridge-owned input intents, never native Tab copies. `pending-input.ts` gives automatic delivery and an explicit Send now action the same ID claim; only the originating live interact connection and pane lease can deliver it. Status comes through the existing collector and CompletionTracker. A committing key is not a new-turn receipt: further automatic sends require a later working/ready cycle. Explicit working-Codex Send now checks its native steering panel (`codex-steering.ts`) before Escape; interruption readiness is not the restarted turn's completion (see docs/development.md). Unknown delivery outcomes never retry. Its checks read `source: "detection"` (a scrolled viewport hides a new menu) and refuse on `modelListWaits`; before the paste, a Claude input box that is not known to be empty (`claudeInputDraft`: typed text, bash mode, a clipped box; the box comes from the detection read, and the viewport's ANSI read, the only one herdr colors, tells grey from typed only when it shows the same box) refuses with `input_draft` and the message is held, never retried — except a box holding exactly the chat's own last message (`claudeRestoredDraft`, Claude Code's copy of a send cancelled before any reply) while the agent is not working, which two Esc presses empty before the send (`freeClaudeBox`, both send paths; the sender's right is asked before each Esc and again when herdr is written to). `GET /api/pane/prompt` answers `restored` only from the live screen's box, with the viewport's colors only where `viewportShowsLive` verifies them; the pending paste and its committing key carry the lease guard to herdr, and a Claude steer commits with Ctrl+Enter (#737); a status read from a snapshot goes in through `mark`/`observe`, so it never overwrites a newer collector event. -- An immediate Claude chat Send runs the same `claudeInputDraft` check before `agent.prompt` and refuses with `input_draft`, naming the draft when the bridge can clear it (`claudeClearableDraft`: typed text on the box's one `❯` row, read with verified colors). A send with `clear_draft` clears only that exact draft, with `CLAUDE_CLEAR_INPUT_KEYS` (ctrl+k, ctrl+u: text edits; never Ctrl+C or Escape, which interrupt a turn), and pastes only once the box reads empty. A queued or terminal-line send never clears. The final draft comparison and clearing keys are not atomic (herdr has no compare-and-clear), so terminal text another client types moments after the user's explicit "Clear it and send" click can also be cleared. -- Malformed client frames get `{type:"error", code:"invalid_json"}` back; the connection is never dropped for a bad frame. -- Malformed roles (`role` with anything but interact/observe) and impossible geometry (non-integer or outside 1..1000 cols/rows) answer `invalid_role` / `invalid_geometry` error frames. -- On `open` every client immediately receives `snapshot`; if that herdr call fails it gets an `error` frame carrying the herdr code or `snapshot_failed`. -- `createServer({ port: 0, stateDir })` boots on an ephemeral port (how the contract tests run it, always with a temp stateDir); `stop()` closes every attachment before `server.stop(true)`. -- Push state (push.ts): VAPID keys load-or-generate lazily on first need; subscriptions keyed by endpoint, persisted via temp+rename 0600; 404/410 → delete + persist, other failures → log + keep. `seed(panes)` replaces titles and only FILLS missing status baselines (an event is newer than any snapshot). Payload = shared/protocol.ts `PushPayload`; TTL 12h; urgency high for blocked. -- The collector calls `onBaseline(snapshot.panes)` on EVERY reconcile (before the same-set early return): without it the first status event after a restart has no baseline and is dropped as a first sighting. -- A pane on a herdr without `terminal attach` gets a `MirrorSession` (mirror.ts) where a `PtySession` would stand, so the attach lifecycle, the late-joiner replay and the output window are unchanged. It reads the visible screen a few times a second and sends the rows that changed in ordinary `pty-data` frames, with the whole screen at the start, after a resize, every few seconds and once the screen goes quiet (whole screens were 135 KB/s against 9 KB/s for changed rows, #261). A repaint carries no cursor position, misses what happened between two reads, and its grid is the pane's own, never the browser's. -- Typing into a mirrored pane goes to `pane.send_text` (mirror-input.ts), and only a pane herdr names an agent on gets bracketed-paste markers around a pasted block: cmd and PowerShell do not read VT input, while a program that does read it receives the markers whether or not it turned paste mode on (#257, #269). -- omo and gjc are not kinds `agent.start` accepts (shell-agent.ts): the pane's shell runs the absolute path `/api/agents` discovered, and the pane's process tree — not its prompt — says when the agent is up. On Windows the shell is PowerShell or cmd, a newline in sent text is a line break in its editor rather than a run, and `process_info` names only the shell, so the shell's children answer instead. -- Queue-delivery submit IDs (`delivery: "queue"`) are idempotency receipts (pending-requests.ts): the same ID with another fingerprint is `invalid_submit_id`, and one already evicted from the bounded book is `retired_submit_id`. Never let either run a second time. An immediate submit keeps no receipt: sent twice, it runs twice. - -## REMOTE BRIDGES -- Machine proxy forwards only allowlisted pane/workspace routes and its own bridge bearer credential. Never forward browser cookies/Authorization or remote Set-Cookie. -- SSH askpass secrets are in memory through private Unix sockets; persistent private keys and registries are 0600. Host-key changes fail closed. -- Remote process replacement requires explicit setup update approval, authenticated bridge/PID verification and managed_remote ownership. Never stop herdr itself or an independent bridge. -- Bundled runtime releases are immutable checksum-addressed directories. MachineRelay carries ACKs unchanged; never acknowledge remote output before the browser has parsed it. - -## WHERE TO LOOK -| Task | Location | Notes | -|------|----------|-------| -| SSH remote PCs | machines.ts + ssh.ts + machine-api.ts + machine-relay.ts; shared/machines.ts | PC-scoped APIs/SSE/WS; private versioned runtimes, same-socket bridge reuse, explicit setup/update approval; docs/remote-pcs.md | -| HTTP routing / static serving | index.ts `createServer().fetch` | route order matters (root AGENTS.md); `/api/machines/local/*` rewrites to `/api/*`; handler modules return a `Response`, or `null` to fall through | -| Updating herdr itself | herdr-update.ts (`HerdrUpdater`, `handleHerdrUpdateRequest`) + GET/POST /api/herdr/update in index.ts | runs `herdr update --handoff` with every `HERDR_*` variable removed, stdin closed and `HERDR_SOCKET_PATH` set to this server's herdr, then `herdr server live-handoff` when `herdr status --json` says the binary differs from the server; only the real entrypoint passes `herdrUpdate`, so tests offer nothing unless they pass a stand-in herdr. NEVER run the real `herdr update` in a test: it replaces the herdr on PATH (#373) | -| Public address through Portal | portal.ts (`PortalService`, `handlePortalRequest`) + GET /api/portal, POST /api/portal/{start,stop} in index.ts | runs the `portal` on PATH and never installs one; refuses to start without a token or under HERDR_WEB_TAILSCALE_SERVE_ONLY, and at boot first stops a Portal the last server left running, so a restart without a token keeps no public address; Portal's environment is PATH, home, temp and proxy variables only. NEVER reach a real relay in a test: tests pass a stand-in `portal` and `fetch` | -| App updates | managed.ts (launcher) + supervisor.ts + updater.ts + update-api.ts + update-state.ts | launcher runs the active release's supervisor and falls back if it dies before ready; supervisor owns Git/build/restart and exits 75 (HANDOVER_EXIT) after an install so the new supervisor takes over; bridge relays status/commands over IPC; /api/updates* gated + POST custom-header/same-origin guard; no update side effects in createServer tests; real Git/build/IPC/rollback tests in updater.test.ts | -| Workspace / agent HTTP endpoints | index.ts + herdr/client.ts | /api/agents and /api/workspace/create/rename/move/close; creation validates cwd and may start an agent in the root pane. `/api/tab/move` forwards an insertion boundary counted before removing the source tab; native `tab.move` returns `tab_list` (the event is `tab_moved`), keeps the active pane and tab, and leaves each tab's number unchanged. | -| Moving a pane | pane-move.ts (`parseMoveRequest`) + POST /api/pane/move in index.ts + `paneMove` in herdr/client.ts | herdr's `pane.move` answers `{ move_result }`; a move inside the workspace keeps the pane id, one into another workspace (or a new one) gives a NEW id (measured on 0.9.3), reported beside `previous_pane_id`, and the emptied tab or workspace closes behind it. The body is checked here because herdr answers a malformed destination with a bare `invalid_request`; a tab destination's `split` defaults to right; `focus` defaults to false so herdr's own focus stays. The old id's transcript cache is forgotten; the collector's `pane.moved` handling (#725) carries the status | -| Plugin actions | GET /api/plugins/actions + POST and GET /api/plugin/action in index.ts; plugin-actions.ts; herdr/client.ts | herdr fills an invocation context field the caller left out, or sent as null, from its OWN focus (measured on 0.9.3: a shell pane got the focused workspace's worktree and the focused pane's agent), so `pluginPaneContext` sends every field: an empty string for what the pane lacks and an all-empty worktree, since herdr refuses a partial one; invoke answers while the command still runs, so POST polls `plugin.log.list` to a deadline and answers `running` with the `log_id` the GET is asked about until it ended; `opened_pane_id` is only the pane the run's own stdout names (`herdr plugin pane open --focus` prints it): the invoke answer names none and a pane carries no plugin ID, so a pane that appeared meanwhile is never attributed; the contract test writes herdr's per-user plugin registry and so runs only in a check run's config (scripts/disposable-config.ts) | -| Slash-command discovery | commands.ts + GET /api/pane/commands in index.ts | per-agent built-ins; Claude user/project `.claude/commands/**/*.md` | -| File mention search | files.ts + GET /api/pane/files in index.ts | git inventory or bounded walk, ranked filtering, 5s cwd cache | -| Chat-lens transcripts | conversation.ts + codex.ts + devin.ts + opencode.ts + GET /api/pane/conversation in index.ts | Codex native rollouts: session metadata/open descriptors first, unique substantial pane-text match for shared app-server TUIs; canonical store paths only, no subagent sessions, no cwd-recency guess. `createServer({ codexHome, devinDbPath, opencodeDb })` isolates test stores. claude/omp use `agent.get`; an omp pane herdr names no omp path for falls back to its omp process's open transcript or terminal breadcrumb (omp.ts, gjc-runtime.ts `heldTranscript`), in the store `--profile`/OMP_PROFILE picks. omo uses its process tree + own store. Devin requires explicit live session identity, never a directory-only guess, and unreadable histories use the terminal-text fallback. OpenCode (opencode.ts): herdr's `herdr:opencode` session id into OpenCode's SQLite store (opened read-only per request), paged by `seq` instead of byte offset: the 2.x store (`session_v2`/`session_message`) when it is there, else the 1.x one (`session`/`message`/`part`) adapted to the same rows. Cache compares inode/size/mtime; `end_ts` records actual assistant activity. | -| Interactive pane prompts | prompt.ts + /api/pane/prompt routes in index.ts | visible-screen parser and responder-specific key/text answers; a prompt's id is the hash of what it says plus its asking (`asked`: a new one after a read without it, a committing key sent from here, or the agent back at work), so an answer to an earlier asking is refused. An answer holds its asking to its last key (no key once it ended), and after ↑/↓ re-reads the menu and the cursor once, before its first key that is not a move (`SETTLE_MS`); the keys after that one go out unseen, as they always did. Reads by other clients while an answer is under way (`answersUnderWay`) neither end nor start an asking. A new responder needs a route test in server/prompt.test.ts for a moved, non-default choice on a redrawn screen: `promptTailIsActive` must accept the cursor on the last row. Claude Code's `/model` list (`claude-model`) is answered with the letter `s` (this session only) and NEVER with its Enter, which saves the pick as the default for every new session; it offers only the rows on screen, its moves after the first each wait for the list to show the one before, a list the reader cannot take gets no fallback card either (`claudeModelListWaits`: that card offers Enter), and herdr never reports the pane blocked while it waits. Claude Code's `/effort` slider (`claude-effort`) follows the same rules: ←/→ to the level under ▲, then `s`, NEVER Enter (it saves the level as the default), and a slider the reader cannot take holds the screen in `modelListWaits` too. Codex's `/model` lists (`codex-model`) follow the same rules, with one more: the footer belongs to the row under the cursor (`enter select` only opens the next list, `enter default · s session` picks), so an answer ends in a `pick` step whose key is read off the screen after the moves (`rowKey`): NEVER choose Enter for a row whose footer offers `s`, and never fix a list's key when the card is read. Enter is sent in the list of models alone ("Select Model and Effort"); in a list of levels the row that opens a list ("More reasoning…") is not offered and no row is answered with Enter, because the rows beside it save a default on that key (maintainer's decision, #503). A list is known by its title, because `enter select · esc back` is the footer of every list Codex draws | -| Model / reasoning metadata | conversation-metadata.ts + conversation.ts cache | Latest recorded settings only: Codex turn_context, omp/omo model_change + thinking_level_change, Claude message.model. HTTP `metadata.model` / `metadata.reasoning_effort` use null for unknown values; never infer effort from thinking text. | -| Background tasks | claude-subagents.ts + omo-tasks.ts + omo-status.ts; `omoTaskResults` / `agentTaskResult` in transcript-records.ts; GET /api/pane/omo-tasks and `background_tasks` in index.ts | OmO reads its `.omo/senpi-task` records. A Claude pane's subagents are read from `/subagents/agent-.{meta.json,jsonl}` and the parent transcript: ended by its `` (one per agent, the newest wins, carried up to three times), for a synchronous call its tool_result, or its own file: an assistant entry with `stop_reason` `end_turn` and no user entry after it (a teammate of an agent team gets no notice); running again when the agent's file has entries after its notice; lost when the pane no longer runs Claude on that session. The newest of notice and answer wins (a launch's "Async agent launched" answer counts for nothing), and an agent last written before the pane's Claude process started is lost. Reads resume where the last stopped (a parent's first read takes its last 16MB, a file 8MB per call, a line over the budget is skipped), the folder listing is kept until the folder changes, files untouched for a day are skipped, ids that become paths are `[A-Za-z0-9_-]+`, and `ClaudeSubagentStatus` looks only at panes whose agent is `claude` and reads nothing while a pane's folder and transcript are as they were. Background commands are read from the same files: a `Bash` answer with `backgroundTaskId` starts one, its `` (any transcript) or a `TaskStop`/`KillShell` answer ends it, and one started before the pane's Claude process is lost; they join the list (category `shell`) and the count. `turnRunning` counts what runs of the turn: started at or after the person's last prompt (`origin` `human`, or a permission mode in older Claude Code; a notice's turn, a queued prompt and another session's message are not prompts). Its count rides `clientSnapshot` and a `pane-status` frame that keeps the status and alerts nothing. background-wait.ts holds a Claude pane at rest while `turnRunning` > 0, or for 60 s after it falls to 0 at rest, and at most 30 min from the rest: such a pane carries `background_wait` on every frame and snapshot, and `alertStatus` (shared/notify-policy.ts) makes it `working` to push, seeds, resync, the remote relay and the browser's alerts, so the turn the notice starts is the same turn and its finish is alerted once. `parseClaudeTranscript` draws an agent's notification as a `task_result`, given the subagents' meta (description, agent type) as an option (it reads no files, and the card never depends on a subagent's growing transcript); a background command's stays hidden | -| Session plan | session-plan.ts; GET /api/pane/plan and `plan` on panes in index.ts | Claude Code task calls and explicit Codex `update_plan` calls are read from the session transcript. Reads resume at the last offset with a bounded budget; code in Codex `exec` calls is never interpreted as a plan. `SessionPlans` follows the pane's transcript and sends a summary in snapshots and `pane-status` frames without changing its status. | -| PTY spawn/write/resize/kill | pty/session.ts (`PtySession`) + pty/pty-host.mjs | sidecar protocol above | -| Web push | push.ts (`createPushService`, `handlePushRequest`) | wired in index.ts: collector `onStatus`/`onPaneEnded` → `push.onStatus/onEnded` (rejections logged, never unhandled), `onBaseline` → `push.seed`, `onResync` (after a lost status stream) → `push.resync`; `lookupTitle` = fresh session.snapshot | -| Image paste | paste.ts (`savePaneImage`, `PasteImageError`) | POST /api/pane/image (index.ts): file under `/.herdr-web-ui/` (0600, tmpdir fallback when cwd is null), or under `HERDR_WEB_PASTE_DIR` when the server's environment sets it (`pasteDirectory`, read on every save: `~/…` is the home directory, a relative value resolves against the server's cwd), ≤8MB png/jpeg/gif/webp; PasteImageError carries its own status (413/415/400) which the route maps to the shared envelope; unknown pane is the usual herdr `pane_not_found` | -| Subscription plan limits | usage.ts + GET /api/usage in index.ts | read-only, from each CLI's own sign-in files; a provider is asked only when a client asks, at most every FRESH_MS unless forced, and a forced ask no more than once per MIN_REFRESH_MS. Where each sign-in lives follows OpenUsage | -| Voice input | voice.ts + /api/voice, /api/voice/config, /api/voice/transcribe in index.ts | BYOK OpenAI key in `stateDir/voice.json` (0600) or `HERDR_WEB_OPENAI_API_KEY`; the route raises Bun's request timeout to 120 s; the transcript streams back as NDJSON and the provider picks its decoder from the file name, so the browser's container has to survive the multipart framing | -| Mirrored terminals | mirror.ts + mirror-input.ts | for a herdr with no `terminal attach`: Windows (herdrdev/herdr#4821) and any bridge runtime that ships no PTY sidecar | -| Anonymous install/update counts | telemetry.ts + /api/telemetry in index.ts; shared/telemetry.ts; receiver in telemetry/ | one event per install and per new version, never before the app's current notice (`NOTICE_VERSION`; a file that saw an older one counts as unseen) was shown (`notice_seen`) and NOTICE_GRACE_MS has passed since, so the notice's Turn off still stops it; turning the switch off aborts a send in flight, and the state file is re-read before each decision (two servers can share it); never while the switch is off or `telemetryBlocked` (HERDR_WEB_TELEMETRY=0, DO_NOT_TRACK, CI, test env); only the real entrypoint passes a `Telemetry`, so tests and remote bridges answer 404 and send nothing. A failed send is retried at the next start, never in a loop. An event carries `settings`: the last browser's allowlisted scalar snapshot, validated by shared/telemetry.ts on both sender and receiver. Raw custom text and account identifiers never enter it; custom choices use flags or counts. Reporting settings to the local server sends no event by itself. Expand the notice version when the disclosed collection scope changes | -| What an update brings | release-notes.ts | CHANGELOG.md sections plus `release-summaries.json`, newest first and bounded; `## [Unreleased]` is no release, and another level-two heading belongs to the notes it sits in | -| Agent stores beyond claude/codex/omo | gjc-runtime.ts, pi.ts, claude-store.ts, omp.ts (transcript-records.ts and skill-activity.ts parse what they find) | each store lookup answers null rather than guessing which transcript a pane writes. pi's store moves with `PI_CODING_AGENT_SESSION_DIR` or `PI_CODING_AGENT_DIR`, and a session file is not written until the first answer, so an absent path can still be the pane's. gjc on Windows is found through the PC's process table, shared between polls for PROCESS_TABLE_MS; an empty table is a failed read, not an answer | -| Paired devices | devices.ts + /api/devices* in index.ts | six-digit code valid ten minutes, one per pairing; the device's token is stored only as a hash in devices.json (0600, written whole), and revoking also closes that device's live streams | -| Shared preferences | preferences.ts + /api/preferences in index.ts; shared/preferences.ts | the settings every browser of this server shares (`SHARED_SETTING_KEYS`) and the sidebar pins, one document in preferences.json (0600, written whole) with a `revision` counter; a PUT carries the settings that changed (laid over the held ones, last write wins per setting) and the pins when they changed, and keeps only the shared names, at most 500 pins and 64 KB; a watching device reads it and is refused a PUT by the general read-only gate; each write goes to every open browser as a `preferences` event on /api/machines/events | - -- Transcript caches are keyed by file, so a pane's chat is remembered against what it read and `forgetPaneTranscriptState(paneId)` (conversation.ts) drops it from every reader (pi-tree, codex, claude-store, gjc-runtime, opencode) when the pane closes or ends. `forgetTranscriptState()` is the test-suite wipe and clears the same state plus the pane index. Any new transcript cache joins one of those two. -- The shell and every static file carry the app's Content-Security-Policy from `serveStatic` (static.ts); `/api/fs/*` answers carry file-view.ts's own stricter one (an HTML page asked for with `render=1` gets `PAGE_POLICY`: its script runs in a sandbox with no origin, no connect-src and nothing from this server or the web; the page's own folder's pictures, scripts and stylesheets are put into it as data: URLs, `pageWithLocalFiles`): scripts and connections are `'self'` only, styles carry `'unsafe-inline'` for the measured layout attributes and KaTeX. `HERDR_WEB_CSP=report-only` measures it without enforcing. A new third-party resource needs a directive there and a reason in the comment. +- Never let an attachment outlive its last client, and never resize from an attach that asked to + `keep_size` or from an observer. +- Never refresh an OAuth token in `usage.ts`: the CLIs rotate refresh tokens, so our refresh signs + the user's CLI out. Report `expired` and leave it to the CLI. +- Revoking every paired device must never reopen the LAN, and a `devices.json` that will not parse + is not a first installation. +- Only proof says herdr is gone: on Windows a dead daemon's pid is reused by the next process. +- The user's API keys go upstream only; no response or error message carries one. +- Only Vite's fingerprinted `/assets/` may be cached long; `sw.js`, the manifest and the app shell + must revalidate, or an installed PWA is pinned to an old build. diff --git a/server/access.test.ts b/server/access.test.ts index e7d3f513f..a68d72050 100644 --- a/server/access.test.ts +++ b/server/access.test.ts @@ -2,13 +2,32 @@ import { describe, expect, it } from "bun:test"; import { cameThroughProxy, decideAccess, isLoopbackAddress, isLoopbackBind, isLoopbackHost, lanExposed, type AccessInput } from "./access.ts"; const device = { id: "d1", label: "Phone", role: "drive" as const }; -const base: AccessInput = { loopback: true, forwarded: false, funnel: false, tailscaleLogin: null, host: null, tokenMatched: false, device: null, owner: null, tagged: false, soleLogin: null, dnsName: null, tailnetIp: null, serveOnly: false, tokenConfigured: false, gated: false }; +const base: AccessInput = { loopback: true, forwarded: false, funnel: false, tailscaleLogin: null, forwardedFor: "100.64.0.9", host: null, tokenMatched: false, device: null, owner: null, tagged: false, soleLogin: null, dnsName: null, tailnetIp: null, serveOnly: false, tokenConfigured: false, gated: false }; const NODE = "pc.tail5cc90b.ts.net"; const IP = "100.64.0.7"; const node = { host: NODE, dnsName: NODE, tailnetIp: IP } as const; const via = (input: Partial) => { const a = decideAccess({ ...base, ...input }); return a.level === "full" ? a.via : `refused:${a.reason}`; }; describe("decideAccess", () => { + for (const forwardedFor of [null, "127.0.0.1", "203.0.113.50", "100.101.102.103, 100.64.0.9", "100.63.255.255", "100.128.0.0", "fd7a:115c:a1e1::1", "100.64.0.999", "::ffff:100.64.0.9"]) { + it(`regression: refuses a login forwarded for ${forwardedFor}`, () => { + const input = { ...base, forwarded: true, tailscaleLogin: "me@example.com", owner: "me@example.com", forwardedFor }; + expect(decideAccess(input)).toEqual({ level: "none", reason: "pairing_required" }); + }); + } + + for (const forwardedFor of ["100.101.102.103", "100.64.0.0", "100.127.255.255", "fd7a:115c:a1e0::1", "FD7A:115C:A1E0:0:0:0:0:1"]) { + it(`control: accepts a login forwarded for ${forwardedFor}`, () => { + const input = { ...base, forwarded: true, tailscaleLogin: "me@example.com", owner: "me@example.com", forwardedFor }; + expect(decideAccess(input)).toMatchObject({ level: "full", via: "tailscale" }); + }); + } + + it("regression: a rejected login never falls through to the serve-only owner grant", () => { + const input = { ...base, ...node, forwarded: true, tailscaleLogin: "me@example.com", owner: "me@example.com", soleLogin: "me@example.com", serveOnly: true, forwardedFor: "127.0.0.1" }; + expect(decideAccess(input)).toEqual({ level: "none", reason: "pairing_required" }); + }); + it("lets this PC in without a token, but not through a proxy", () => { expect(via({})).toBe("local"); expect(via({ forwarded: true, gated: true })).toBe("refused:pairing_required"); @@ -141,13 +160,20 @@ describe("lanExposed", () => { it("names the one state a warning belongs to: bound past this PC, no token, nothing paired", () => { expect(lanExposed(open)).toBe(true); - // every address, the LAN, the tailnet, a name: all reachable beyond this PC - for (const hostname of ["::", "[::]", "192.168.0.10", "100.64.0.2", "fd7a::1", "my-pc.local", "127.devbox.example", "localhost.example"]) { + // wildcard, LAN, other IPv6 and DNS binds still warn, even on a PC with Tailscale + for (const hostname of ["::", "[::]", "192.168.0.10", "fd7a::1", "my-pc.local", "127.devbox.example", "localhost.example", "100.63.255.255", "100.128.0.0", "fd7a:115c:a1df:ffff:ffff:ffff:ffff:ffff", "fd7a:115c:a1e1::", "100.64.0.999", "100.64.0.2.example"]) { expect(isLoopbackBind(hostname)).toBe(false); expect(warned({ hostname })).toBe(true); } }); + for (const hostname of ["100.64.0.0", "100.64.0.2", "100.127.255.255", "fd7a:115c:a1e0::", "fd7a:115c:a1e0::1", "fd7a:115c:a1e0:ffff:ffff:ffff:ffff:ffff", "FD7A:115C:A1E0:0:0:0:0:1", "[fd7a:115c:a1e0::1]", " 100.64.0.2 "]) { + it(`does not warn for a Tailscale-only bind at ${hostname}`, () => { + expect(isLoopbackBind(hostname)).toBe(false); + expect(warned({ hostname })).toBe(false); + }); + } + it("is quiet whenever one of the three is not the case", () => { // a loopback bind is unreachable from the network however it is addressed or spelt for (const hostname of ["127.0.0.1", "localhost", "::1", "127.0.0.2", "[::1]", "LOCALHOST", "::ffff:127.0.0.1", "app.localhost", " localhost "]) { diff --git a/server/access.ts b/server/access.ts index 1d3a555fc..85da63b26 100644 --- a/server/access.ts +++ b/server/access.ts @@ -26,6 +26,7 @@ * that sends neither (nginx's bare `proxy_pass` rewrites Host to the upstream address and adds * nothing) cannot be told from a local client: only a token closes that setup. */ +import { BlockList, isIP } from "node:net"; import type { AccessRefusal, AccessVia, DeviceRole } from "../shared/protocol.ts"; import type { DeviceMatch } from "./devices.ts"; @@ -38,6 +39,8 @@ export interface AccessInput { funnel: boolean; /** Tailscale-User-Login: set by tailscale serve for a person's device, absent for tagged nodes */ tailscaleLogin: string | null; + /** X-Forwarded-For: Serve replaces this with one source address, never a proxy chain */ + forwardedFor?: string | null; /** the request's Host header, as sent */ host: string | null; /** the shared token matched (cookie or bearer) */ @@ -57,7 +60,7 @@ export interface AccessInput { /** the operator declared `tailscale serve` as this install's only ingress (HERDR_WEB_TAILSCALE_SERVE_ONLY) */ serveOnly: boolean; tokenConfigured: boolean; - /** a device has been paired at some point: the gate is closed to strangers (server/devices.ts) */ + /** a device cookie was confirmed by DeviceStore.match: the gate is closed to strangers */ gated: boolean; } @@ -83,16 +86,19 @@ export function isLoopbackBind(hostname: string): boolean { } /** - * Is the server answering on an address beyond this PC while no access token is set and no + * Is the server answering beyond this PC and its tailnet while no access token is set and no * device has ever been paired? Every client that can reach that address is let in as `open` * (see `decideAccess`), so this is the state a warning belongs to — told to whoever already * got in, never used to change a default. * * `hostname` is the address the server is bound to, not the one the request arrived on: a - * loopback bind with an unset token is unreachable from the network however it is addressed. + * loopback bind is local, and a Tailscale-only bind is reachable only within the tailnet. */ export function lanExposed(input: { hostname: string; tokenConfigured: boolean; gated: boolean }): boolean { - return !isLoopbackBind(input.hostname) && !input.tokenConfigured && !input.gated; + const address = input.hostname.trim().toLowerCase().replace(/^\[(.*)\]$/, "$1"); + const family = isIP(address); + const tailnet = family === 4 ? tailnetIpv4.check(address, "ipv4") : family === 6 && tailnetIpv6.check(address, "ipv6"); + return !isLoopbackBind(input.hostname) && !tailnet && !input.tokenConfigured && !input.gated; } /** Headers a proxy adds and a browser or CLI on this PC has no reason to send; a Tailscale login is stated by a proxy too. */ @@ -140,6 +146,14 @@ export function isServeOwnerRequest(input: Pick { export async function resolveAgyStore(pane: HerdrPane): Promise { if (pane.agent !== "agy") return null; try { - const info = await herdrRpc<{ process_info?: { foreground_processes?: { pid: number; name?: string; argv?: string[] }[] } }>("pane.process_info", { pane_id: pane.pane_id }); - const owners = (info.process_info?.foreground_processes ?? []).filter((p) => p.name === "agy" && Number.isSafeInteger(p.pid) && p.pid > 1); + const info = await paneProcessInfo(pane.pane_id); + const owners = (info?.foreground_processes ?? []).filter((p) => p.name === "agy" && Number.isSafeInteger(p.pid) && p.pid > 1); if (owners.length !== 1) return null; const pid = owners[0]!.pid; const before = await processIdentity(pid); diff --git a/server/api.contract.test.ts b/server/api.contract.test.ts index c03d72876..e03258046 100644 --- a/server/api.contract.test.ts +++ b/server/api.contract.test.ts @@ -14,13 +14,15 @@ import { VoiceService } from "./voice.ts"; import { Telemetry } from "./telemetry.ts"; import { NOTICE_VERSION, type TelemetryEvent, type TelemetryStatus } from "../shared/telemetry.ts"; import { DEFAULT_SETTINGS, telemetrySettingsSnapshot } from "../src/lib/settings.ts"; -import { herdrRpc, ping, sessionSnapshot, tabCreate, workspaceCreate, workspaceClose } from "./herdr/client.ts"; +import { HerdrSocket } from "../src/lib/ws.ts"; +import { herdrRpc, paneRead, ping, sessionSnapshot, tabCreate, workspaceCreate, workspaceClose } from "./herdr/client.ts"; import { startFakePushService, type FakePushService } from "./push.fake.ts"; import { descriptorPath, type BridgeDescriptor } from "./bridge.ts"; import { handleMachineRequest } from "./machine-api.ts"; import type { MachineManager } from "./machines.ts"; import { TailnetIdentitySource } from "./tailscale.ts"; import { forgetTranscriptState } from "./conversation.ts"; +import type { RepoDiffFileList, RepoDiffFilePatch } from "../shared/protocol.ts"; /** * Contract test for herdr-web-ui's HTTP + WS surface. @@ -651,7 +653,7 @@ describe("mutation body validation", () => { it("rejects non-object JSON without touching herdr or losing the error envelope", async () => { for (const path of [ "/api/workspace/create", "/api/tab/create", "/api/tab/rename", "/api/tab/close", "/api/tab/move", "/api/workspace/rename", "/api/workspace/move", "/api/workspace/close", - "/api/pane/rename", "/api/pane/input", "/api/pane/keys", "/api/pane/close", "/api/pane/move", "/api/pane/image", + "/api/pane/rename", "/api/pane/input", "/api/pane/keys", "/api/pane/close", "/api/pane/image", "/api/pane/scroll", "/api/agent/rename", ]) { for (const body of [null, [], "text", 42, true]) { @@ -706,6 +708,9 @@ describe("tab rename and close", () => { expect(res.status).toBe(400); expect(((await res.json()) as ApiError).error.code).toBe("missing_label"); } + const long = await post("rename", { tab_id: "unknown:t9", label: "T".repeat(81) }); + expect(long.status).toBe(400); + expect(((await long.json()) as ApiError).error.code).toBe("label_too_long"); }); it("renames a tab, closes one beside another, and takes the workspace with its last tab", async () => { @@ -903,6 +908,7 @@ describe("tab creation", () => { [{ cwd: join(tmpdir(), crypto.randomUUID()) }, "invalid_cwd"], [{ cwd: 42 }, "invalid_cwd"], [{ label: false }, "missing_label"], + [{ label: "T".repeat(81) }, "label_too_long"], [{ agent: {} }, "invalid_agent"], [{ agent: { kind: "muse", args: "--help" } }, "invalid_agent"], ] as const) { @@ -1094,8 +1100,10 @@ describe("workspace and discovery endpoints", () => { expect((await kinds()).map((agent) => agent.kind)).not.toContain("gjc"); for (const name of ["omo", "gjc"]) writeFileSync(join(bin, name), "#!/bin/sh\n", { mode: 0o755 }); const offered = await kinds(); - expect(offered).toContainEqual({ kind: "omo", label: "OmO" }); - expect(offered).toContainEqual({ kind: "gjc", label: "Gajae Code" }); + expect(offered).toContainEqual({ kind: "omo", label: "OmO", installed: true }); + expect(offered).toContainEqual({ kind: "gjc", label: "Gajae Code", installed: true }); + // claude is always listed, and said to be missing where its command is not on PATH + expect(offered).toContainEqual({ kind: "claude", label: "Claude Code", installed: false }); } finally { process.env["PATH"] = path; rmSync(bin, { recursive: true, force: true }); @@ -1162,6 +1170,48 @@ describe("workspace and discovery endpoints", () => { workspaceId = null; }, 20_000); + it("pushes session-changed when POST /api/workspace/close answers, not after the collector's debounce", async () => { + const closing = await workspaceCreate({ cwd: tmpdir(), label: "herdr-web-ui-test-close-push" }); + const socket = await RecordingSocket.connect(`ws://localhost:${server.port}/ws`); + try { + // the creation's own frame, debounced, is not the close's + await socket.waitFor((frame) => frame.type === "session-changed", "session-changed after create", 5_000); + const before = new Set(socket.seen); + const changed = socket.waitFor((frame) => frame.type === "session-changed" && !before.has(frame), "session-changed after close", 5_000).then(() => performance.now()); + const response = await fetch(`${base()}/api/workspace/close`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ workspace_id: closing.workspace.workspace_id }), + }); + const answered = performance.now(); + expect(response.status).toBe(200); + expect(await changed - answered).toBeLessThan(250); + } finally { + socket.close(); + await herdrRpc("workspace.close", { workspace_id: closing.workspace.workspace_id }).catch(() => undefined); + } + }, 20_000); + + it("says a repository has no commits yet instead of passing git's refusal on", async () => { + const repo = mkdtempSync(join(tmpdir(), "herdr-web-ui-worktree-empty-")); + expect(Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: repo, stdout: "pipe", stderr: "pipe" }).exitCode).toBe(0); + const parent = await workspaceCreate({ cwd: repo, label: "herdr-web-ui-test-worktree-empty" }); + const checkout = join(`${repo}-checkouts`, "feature"); + try { + const create = await fetch(`${base()}/api/worktree/create`, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ workspace_id: parent.workspace.workspace_id, branch: "herdr-web-ui-test-unborn", path: checkout }), + }); + expect(create.status).toBe(409); + expect(((await create.json()) as ApiError).error.code).toBe("repository_without_commits"); + expect(existsSync(checkout)).toBeFalse(); + } finally { + await herdrRpc("workspace.close", { workspace_id: parent.workspace.workspace_id }).catch(() => undefined); + rmSync(repo, { recursive: true, force: true }); + rmSync(`${repo}-checkouts`, { recursive: true, force: true }); + } + }, 20_000); + it("creates a worktree of an owned repository as a grouped workspace, lists it, and opens it again", async () => { // a repository with one commit: a worktree needs a branch to start from const repo = mkdtempSync(join(tmpdir(), "herdr-web-ui-worktree-")); @@ -1332,6 +1382,12 @@ describe("workspace and discovery endpoints", () => { it("uses the shared error envelope for malformed mutation bodies", async () => { for (const [path, body, code] of [ ["/api/workspace/rename", {}, "missing_workspace_id"], + // #927: herdr would keep a blank or any-length name + ["/api/workspace/rename", { workspace_id: "x", label: "" }, "missing_label"], + ["/api/workspace/rename", { workspace_id: "x", label: " " }, "missing_label"], + ["/api/workspace/rename", { workspace_id: "x", label: "W".repeat(81) }, "label_too_long"], + ["/api/worktree/create", { workspace_id: "x", branch: "b", label: "W".repeat(81) }, "label_too_long"], + ["/api/worktree/open", { workspace_id: "x", path: "/tmp", label: "W".repeat(81) }, "label_too_long"], ["/api/workspace/move", { workspace_id: "x", insert_index: -1 }, "invalid_index"], ["/api/workspace/close", {}, "missing_workspace_id"], ["/api/pane/rename", {}, "missing_pane_id"], @@ -1633,12 +1689,57 @@ describe("POST /api/pane/image", () => { expect(basename(bare.path)).toMatch(/^file-.*\.bin$/); }); - it("rejects an image above the size ceiling with 413", async () => { - const oversized = Buffer.alloc(9 * 1024 * 1024, 1).toString("base64"); + it("stores a 20 MiB file whole (regression: 413 while the ceiling was 8 MiB)", async () => { + const bytes = Buffer.alloc(20 * 1024 * 1024, 7); + const res = await post({ pane_id: qaPaneId, content_type: "application/zip", data_base64: bytes.toString("base64"), name: "big.zip" }); + expect(res.status).toBe(200); + const { path } = (await res.json()) as { path: string }; + expect(statSync(path).size).toBe(bytes.byteLength); + }); + + it("rejects a 40 MiB file (a 55.9 MB body, under Bun's 64 MiB cap) with 413 and the JSON reason (control)", async () => { + const oversized = Buffer.alloc(40 * 1024 * 1024, 1).toString("base64"); const res = await post({ pane_id: qaPaneId, content_type: "image/png", data_base64: oversized }); expect(res.status).toBe(413); - const body = (await res.json()) as { error: { code: string } }; + const body = (await res.json()) as { error: { code: string; message: string } }; expect(body.error.code).toBe("image_too_large"); + expect(body.error.message.length).toBeGreaterThan(0); + }); + + it("counts a streamed body that announces no length and refuses it past the bound (regression: invalid_json on main)", async () => { + const chunk = new TextEncoder().encode("A".repeat(1024 * 1024)); + let sent = 0; + const stream = new ReadableStream({ + pull(controller) { + if (sent === 0) controller.enqueue(new TextEncoder().encode(`{"pane_id":"${qaPaneId}","content_type":"application/zip","data_base64":"`)); + if (sent++ < 60) controller.enqueue(chunk); + else controller.close(); + }, + }); + const res = await fetch(`${base()}/api/pane/image`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: stream, + duplex: "half", + } as RequestInit); + expect(res.status).toBe(413); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe("image_too_large"); + }); + + it("passes a 20 MiB upload through a PC proxy route (regression: the remote refused it above 8 MiB)", async () => { + const manager = { + endpoint: () => ({ url: base(), token: "" }), + trackTerminal: () => () => undefined, + } as unknown as MachineManager; + const bytes = Buffer.alloc(20 * 1024 * 1024, 9); + const res = await handleMachineRequest(new Request("http://127.0.0.1/api/machines/pc1/pane/image", { + method: "POST", + headers: { "content-type": "application/json", "x-herdr-machine": "1" }, + body: JSON.stringify({ pane_id: qaPaneId, content_type: "application/zip", data_base64: bytes.toString("base64"), name: "remote.zip" }), + }), manager); + expect(res.status).toBe(200); + const { path } = (await res.json()) as { path: string }; + expect(statSync(path).size).toBe(bytes.byteLength); }); it("rejects empty image data with 400", async () => { @@ -1925,18 +2026,229 @@ describe("WebSocket roles and status push", () => { // the terminal lens's grid is the pty's, not the pane's own one the covered attach left it at expect(await phone.waitFor((m) => m.type === "pane-geometry" && m.pane_id === paneId && m.cols === 91, "the resize applied after creation", 15_000)).toMatchObject({ cols: 91, rows: 27 }); await phone.waitFor((m) => m.type === "input-ready" && m.pane_id === paneId, "attach took", 15_000); - // one word, "27x91": herdr may draw a space as a cursor move, and "size=27 91" then never arrives as such - const from = phone.seen.length; + // regression (fails on main): inspect the rendered screen, not diff-rendered PTY bytes phone.send({ type: "input", pane_id: paneId, text: "clear; echo size=$(stty size | tr ' ' x)\r" }); - // over every frame since the command: a read may end in the middle of the word - const output = () => phone.seen.slice(from).filter((m) => m.type === "pty-data" && m.pane_id === paneId).map((m) => String(m.data)).join(""); - await phone.waitFor((m) => m.type === "pty-data" && m.pane_id === paneId && output().includes("size=27x91"), "the shell sees the terminal lens's grid", 15_000); + const deadline = Date.now() + 15_000; + let screen = ""; + let lastError: unknown; + while (Date.now() <= deadline) { + // one read that times out is not the verdict: keep polling until the deadline + try { + screen = (await paneRead({ paneId, source: "visible", format: "text", timeoutMs: 1_000 })).text; + } catch (error) { + lastError = error; + continue; + } + if (screen.includes("size=27x91")) break; + } + expect(screen, `the shell sees the terminal lens's grid (last pane.read error: ${String(lastError)})`).toContain("size=27x91"); } finally { phone.close(); await workspaceClose(created.workspace.workspace_id).catch(() => undefined); } }, 60_000); + // #472: a device that goes back to its chat lens gives the shared pty back to a terminal still + // showing, without waiting for that terminal's own resize, focus or click + describe("release-size", () => { + const ws = () => RecordingSocket.connect(`ws://localhost:${server.port}/ws`); + // the shell's `stty size` goes to a file of its own per ask: herdr redraws the pane's screen as + // cell updates, so the same answer twice never reads back from the stream. Asked again until the + // deadline: herdr applies a pty resize to the pane a moment after it. + let asks = 0; + const shellSees = async (socket: RecordingSocket, paneId: string, size: string) => { + const want = size.replace("x", " "); + let last = ""; + for (const deadline = Date.now() + 15_000; Date.now() < deadline;) { + const file = join(tmpdir(), `herdr-web-ui-release-size-${process.pid}-${++asks}`); + socket.send({ type: "input", pane_id: paneId, text: `stty size > ${file}.part && mv ${file}.part ${file}\r` }); + for (const answered = Date.now() + 1000; Date.now() < answered && !existsSync(file);) await Bun.sleep(25); + if (!existsSync(file)) continue; + last = readFileSync(file, "utf8").trim(); + rmSync(file, { force: true }); + if (last === want) return; + } + throw new Error(`the shell saw "${last}", not "${want}"`); + }; + const attach = async (socket: RecordingSocket, paneId: string, cols: number, rows: number) => { + socket.send({ type: "attach", pane_id: paneId, cols, rows }); + await socket.waitFor((m) => m.type === "input-ready" && m.pane_id === paneId, "attach took", 15_000); + }; + + it("returns the pty to the desktop's grid when the phone leaves the terminal lens", async () => { + const created = await workspaceCreate({ cwd: tmpdir(), label: "herdr-web-ui-test-release-size" }); + const paneId = created.root_pane.pane_id; + const desktop = await ws(); + const phone = await ws(); + try { + await attach(desktop, paneId, 120, 40); + // the phone opens the terminal lens: its viewport takes the shared grid + await attach(phone, paneId, 53, 33); + await desktop.waitFor((m) => m.type === "pane-geometry" && m.pane_id === paneId && m.cols === 53, "the phone took the grid", 5000); + await shellSees(desktop, paneId, "33x53"); + + desktop.seen.length = 0; + phone.send({ type: "release-size", pane_id: paneId }); + // regression (fails on main): the desktop's grid comes back within 1 s, with no desktop frame sent + expect(await desktop.waitFor((m) => m.type === "pane-geometry" && m.pane_id === paneId, "the grid given back", 5000)).toMatchObject({ cols: 120, rows: 40 }); + // regression (fails on main): the shell in the pane sees the desktop's grid + await shellSees(desktop, paneId, "40x120"); + // control: one server resize for the release, no ping-pong after it + expect(desktop.seen.filter((m) => m.type === "pane-geometry" && m.pane_id === paneId)).toHaveLength(1); + } finally { + desktop.close(); + phone.close(); + await workspaceClose(created.workspace.workspace_id).catch(() => undefined); + } + }, 60_000); + + it("skips a hidden client's grid when another client releases, leaving the size if nobody shows a terminal", async () => { + const created = await workspaceCreate({ cwd: tmpdir(), label: "herdr-web-ui-test-release-size-hidden" }); + const paneId = created.root_pane.pane_id; + const desktop = await ws(); + const phone = await ws(); + // Use the actual browser client for keepSize, not a hand-written release frame: the bug is + // that a mounted but hidden terminal used to leave its grid on the server. + const globals = globalThis as unknown as { window?: unknown }; + const beforeWindow = globals.window; + globals.window = globalThis; + const hidden = new HerdrSocket(`ws://localhost:${server.port}/ws`); + const nextHidden = (matches: (message: import("../shared/protocol.ts").ServerMessage) => boolean) => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { off(); reject(new Error("hidden client did not answer")); }, 15_000); + const off = hidden.on((message) => { + const ack = message.type === "pty-data" ? hidden.outputAcknowledgement(message) : undefined; + ack?.(); + if (matches(message)) { clearTimeout(timeout); off(); resolve(); } + }); + }); + try { + await attach(desktop, paneId, 120, 40); + const ready = nextHidden((m) => m.type === "input-ready" && m.pane_id === paneId); + hidden.attach(paneId, 100, 30); + hidden.connect(); + await ready; + await desktop.waitFor((m) => m.type === "pane-geometry" && m.cols === 100, "hidden client's initial grid", 5000); + await attach(phone, paneId, 53, 33); + await desktop.waitFor((m) => m.type === "pane-geometry" && m.cols === 53, "phone's grid", 5000); + await shellSees(desktop, paneId, "33x53"); + + // An in-band response on the same connection orders keepSize before the phone releases. + // A nonexistent pending ID changes nothing and sends no terminal input. + hidden.keepSize(paneId); + expect(await hidden.pendingAction(paneId, "nonexistent-472", "discard")).toMatchObject({ ok: false }); + desktop.seen.length = 0; + phone.send({ type: "release-size", pane_id: paneId }); + // regression (fails on main): the hidden 100x30 candidate must not win. + expect(await desktop.waitFor((m) => m.type === "pane-geometry" && m.pane_id === paneId, "remaining terminal's grid", 5000)) + .toMatchObject({ cols: 120, rows: 40 }); + await shellSees(desktop, paneId, "40x120"); + expect(desktop.seen.filter((m) => m.type === "pane-geometry")).toHaveLength(1); + + desktop.seen.length = 0; + desktop.send({ type: "release-size", pane_id: paneId }); + desktop.send({ type: "ping", id: 47221 }); + await desktop.waitFor((m) => m.type === "pong" && m.id === 47221, "last terminal released", 5000); + // control: no terminal viewers remain; do not resize to a hidden or Chat grid. + expect(desktop.seen.filter((m) => m.type === "pane-geometry")).toEqual([]); + await shellSees(desktop, paneId, "40x120"); + } finally { + hidden.close(); + globals.window = beforeWindow; + desktop.close(); + phone.close(); + await workspaceClose(created.workspace.workspace_id); + } + }, 60_000); + + it("keeps a grid released during attach creation out of the fallback candidates", async () => { + const created = await workspaceCreate({ cwd: tmpdir(), label: "herdr-web-ui-test-release-size-creating" }); + const paneId = created.root_pane.pane_id; + const hidden = await ws(); + const phone = await ws(); + try { + // Back-to-back on the same connection: release lands during the initial terminal lookup. + hidden.send({ type: "attach", pane_id: paneId, cols: 100, rows: 30 }); + hidden.send({ type: "release-size", pane_id: paneId }); + hidden.send({ type: "ping", id: 47222 }); + await hidden.waitFor((m) => m.type === "pong" && m.id === 47222, "release processed during creation", 5000); + await hidden.waitFor((m) => m.type === "input-ready", "hidden attach completed", 15_000); + await attach(phone, paneId, 53, 33); + await hidden.waitFor((m) => m.type === "pane-geometry" && m.cols === 53, "phone grid", 5000); + await shellSees(phone, paneId, "33x53"); + hidden.seen.length = 0; + phone.send({ type: "release-size", pane_id: paneId }); + phone.send({ type: "ping", id: 47223 }); + await phone.waitFor((m) => m.type === "pong" && m.id === 47223, "phone release processed", 5000); + // regression (fails on main): the completed attach must not revive a released grid. + expect(hidden.seen.filter((m) => m.type === "pane-geometry")).toEqual([]); + await shellSees(phone, paneId, "33x53"); + } finally { + hidden.close(); + phone.close(); + await workspaceClose(created.workspace.workspace_id); + } + }, 60_000); + + it("never overwrites a resize another client sends after the release", async () => { + const created = await workspaceCreate({ cwd: tmpdir(), label: "herdr-web-ui-test-release-size-race" }); + const paneId = created.root_pane.pane_id; + const desktop = await ws(); + const phone = await ws(); + const third = await ws(); + try { + await attach(desktop, paneId, 120, 40); + await attach(phone, paneId, 53, 33); + // the third client watches from the chat lens until it resizes + third.send({ type: "attach", pane_id: paneId, cols: 30, rows: 10, keep_size: true }); + await third.waitFor((m) => m.type === "input-ready" && m.pane_id === paneId, "third attach took", 15_000); + await desktop.waitFor((m) => m.type === "pane-geometry" && m.pane_id === paneId && m.cols === 53, "the phone took the grid", 5000); + + desktop.seen.length = 0; + phone.send({ type: "release-size", pane_id: paneId }); + third.send({ type: "resize", pane_id: paneId, cols: 100, rows: 30 }); + await desktop.waitFor((m) => m.type === "pane-geometry" && m.pane_id === paneId && m.cols === 100, "the third client's resize", 5000); + // control: the pty ends at the later resize, whichever of the two frames the server read first + await shellSees(third, paneId, "30x100"); + // control: at most the release's own resize before the third client's, nothing after it + const grids = desktop.seen.filter((m) => m.type === "pane-geometry" && m.pane_id === paneId); + expect(grids.length).toBeLessThanOrEqual(2); + expect(grids.at(-1)).toMatchObject({ cols: 100, rows: 30 }); + } finally { + for (const socket of [desktop, phone, third]) socket.close(); + await workspaceClose(created.workspace.workspace_id).catch(() => undefined); + } + }, 60_000); + + it("ignores a release from a client that never drove the grid, as an older server ignores the frame", async () => { + const created = await workspaceCreate({ cwd: tmpdir(), label: "herdr-web-ui-test-release-size-noop" }); + const paneId = created.root_pane.pane_id; + const desktop = await ws(); + const chat = await ws(); + try { + await attach(desktop, paneId, 120, 40); + chat.send({ type: "attach", pane_id: paneId, cols: 53, rows: 33, keep_size: true }); + await chat.waitFor((m) => m.type === "input-ready" && m.pane_id === paneId, "chat attach took", 15_000); + desktop.seen.length = 0; + chat.seen.length = 0; + chat.send({ type: "release-size", pane_id: paneId }); + // also for a pane this connection never attached + chat.send({ type: "release-size", pane_id: "w-nonexistent:p1" }); + chat.send({ type: "ping", id: 472 }); + // control (passes on main): the connection stays open and answers, with no error frame + await chat.waitFor((m) => m.type === "pong" && m.id === 472, "pong after release-size", 5000); + expect(chat.seen.filter((m) => m.type === "error")).toEqual([]); + // control (passes on main): the grid stays the desktop's + await shellSees(desktop, paneId, "40x120"); + expect(desktop.seen.filter((m) => m.type === "pane-geometry")).toEqual([]); + } finally { + desktop.close(); + chat.close(); + await workspaceClose(created.workspace.workspace_id).catch(() => undefined); + } + }, 60_000); + }); + it("rejects an unknown role mode with an in-band error", async () => { const client = await RecordingSocket.connect(`ws://localhost:${server.port}/ws`); try { @@ -2254,6 +2566,30 @@ describe("web push", () => { expect(((await gone.json()) as ApiError).error.code).toBe("subscription_not_found"); }); + // regression (fails on main): an endpoint the push service retired is refused, never-known stays not found (#1038) + it.each([404, 410])("answers subscription_gone on the first test when the push service returns %i", async (status) => { + const retiring = await startFakePushService(); + try { + expect((await postJson("/api/push/subscribe", { subscription: retiring.subscription })).status).toBe(204); + retiring.answerWith(status); + const first = await postJson("/api/push/test", { endpoint: retiring.subscription.endpoint }); + expect(first.status).toBe(410); + expect(((await first.json()) as ApiError).error.code).toBe("subscription_gone"); + const again = await postJson("/api/push/subscribe", { subscription: retiring.subscription }); + expect(again.status).toBe(410); + expect(((await again.json()) as ApiError).error.code).toBe("subscription_gone"); + const tested = await postJson("/api/push/test", { endpoint: retiring.subscription.endpoint }); + expect(tested.status).toBe(410); + expect(((await tested.json()) as ApiError).error.code).toBe("subscription_gone"); + // control + const unknown = await postJson("/api/push/test", { endpoint: "https://push.example/never-known" }); + expect(unknown.status).toBe(404); + expect(((await unknown.json()) as ApiError).error.code).toBe("subscription_not_found"); + } finally { + retiring.stop(); + } + }); + it("pushes a live pane's status change to a subscribed device", async () => { const paneId = pushPaneId!; expect((await postJson("/api/push/subscribe", { subscription: fake.subscription })).status).toBe(204); @@ -2541,6 +2877,18 @@ describe("pairing and identity", () => { expect(((await refused.json()) as ApiError).error.code).toBe("other_user"); }); + it("regression: refuses identity headers without a single tailnet forwarded address", async () => { + for (const forwardedFor of [null, "127.0.0.1", "203.0.113.50", "100.64.0.9, 100.64.0.10"]) { + const headers = { "tailscale-user-login": OWNER, ...(forwardedFor === null ? {} : { "x-forwarded-for": forwardedFor }) }; + const response = await fetch(`${base()}/api/devices`, { headers }); + expect(response.status).toBe(401); + expect(((await response.json()) as ApiError).error.code).toBe("unauthorized"); + expect(await auth(headers)).toMatchObject({ authenticated: false, reason: "pairing_required" }); + } + // control: Serve may forward either tailnet address family. + expect((await fetch(`${base()}/api/devices`, { headers: proxied(OWNER, { "x-forwarded-for": "fd7a:115c:a1e0::1" }) })).status).toBe(200); + }); + it("lets the owner's own device in with no login header, where serve is the only ingress and one login owns the tailnet", async () => { const state = mkdtempSync(join(tmpdir(), "herdr-sole-user-")); const savedOwner = process.env["HERDR_WEB_TAILSCALE_OWNER"]; @@ -2572,6 +2920,8 @@ describe("pairing and identity", () => { expect(await at(sole.port, { ...phone, "tailscale-user-login": "someone@example.com" })).toMatchObject({ authenticated: false, reason: "other_user" }); expect(await at(sole.port, { ...phone, "tailscale-funnel-request": "?1" })).toMatchObject({ authenticated: false, reason: "pairing_required" }); expect(await at(sole.port, { ...phone, "tailscale-user-login": OWNER })).toMatchObject({ authenticated: true, via: "tailscale" }); + // regression: a rejected identity is not an absent identity, even at this PC's Host. + expect(await at(sole.port, { ...phone, "tailscale-user-login": OWNER, "x-forwarded-for": "127.0.0.1" })).toMatchObject({ authenticated: false, reason: "pairing_required" }); // where the operator did not declare serve the only ingress, the same request pairs, with or without the tailnet's name as Host expect(await at(off.port, proxied())).toMatchObject({ authenticated: false, reason: "pairing_required" }); expect(await at(off.port, phone)).toMatchObject({ authenticated: false, reason: "pairing_required" }); @@ -2809,6 +3159,9 @@ describe("token auth", () => { const { code } = (await (await fetch(`http://127.0.0.1:${open.port}/api/devices/pair/start`, { method: "POST", headers: guard })).json()) as { code: string }; const paired = await fetch(`http://127.0.0.1:${open.port}/api/devices/pair`, { method: "POST", headers: { ...guard, "x-forwarded-for": "100.64.0.9" }, body: JSON.stringify({ code, label: "Phone" }) }); expect(paired.status).toBe(204); + expect((await health(open.port)).lan_exposure).toEqual({ host: "0.0.0.0" }); + const cookie = paired.headers.get("set-cookie")!.split(";")[0]!; + expect((await health(open.port, { cookie })).auth).toMatchObject({ authenticated: true, via: "device" }); expect((await health(open.port)).auth).toMatchObject({ authenticated: true, via: "local" }); expect((await health(open.port)).lan_exposure).toBeUndefined(); } finally { @@ -3037,3 +3390,58 @@ it("refuses cross-origin changes while allowing same-origin and CLI requests", a } } finally { instance.stop(); await workspaceClose(created.workspace.workspace_id); rmSync(root, { recursive: true, force: true }); } }); + +describe("repository diff of a pane's folder", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-web-ui-test-repo-diff-"))); + let workspaceId: string | null = null; + afterAll(async () => { + if (workspaceId) await workspaceClose(workspaceId).catch(() => undefined); + rmSync(root, { recursive: true, force: true }); + }); + + it("reads the repository the pane sits in, a folder named from it, and a named folder once the pane is gone", async () => { + const git = (...args: string[]) => { + const result = Bun.spawnSync(["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", "-C", root, ...args], { stdout: "ignore", stderr: "pipe" }); + if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString()}`); + }; + git("init", "-q", "-b", "main"); + mkdirSync(join(root, "sub")); + writeFileSync(join(root, "a.txt"), "one\n"); + git("add", "."); + git("commit", "-q", "-m", "first"); + writeFileSync(join(root, "a.txt"), "two\n"); + // the pane sits below the repository's top, and in no repository of its own + const created = await workspaceCreate({ cwd: join(root, "sub"), label: "herdr-web-ui-test-repo-diff" }); + workspaceId = created.workspace.workspace_id; + const pane = `pane_id=${encodeURIComponent(created.root_pane.pane_id)}`; + const expected: RepoDiffFileList["files"] = [{ path: "a.txt", old_path: null, status: "modified", additions: 1, deletions: 1, binary: false }]; + for (const url of [ + `${base()}/api/workspace/diff/files?${pane}`, + `${base()}/api/machines/local/workspace/diff/files?${pane}`, + `${base()}/api/workspace/diff/files?${pane}&directory=..`, + `${base()}/api/workspace/diff/files?pane_id=no-such-pane&directory=${encodeURIComponent(root)}`, + ]) { + const response = await fetch(url); + expect([url, response.status]).toEqual([url, 200]); + const list = (await response.json()) as RepoDiffFileList; + expect(list.repository).toMatchObject({ root, branch: "main", detached: false, unborn: false }); + expect(list.files).toEqual(expected); + } + const file = await fetch(`${base()}/api/workspace/diff/file?${pane}&path=a.txt`); + expect(file.status).toBe(200); + const answer = (await file.json()) as RepoDiffFilePatch; + expect(answer).toMatchObject({ file: expected[0], unavailable: null, truncated: false }); + expect(answer.patch).toStartWith("diff --git a/a.txt b/a.txt\n"); + expect(answer.patch).toEndWith("-one\n+two\n"); + + const gone = await fetch(`${base()}/api/workspace/diff/files?pane_id=no-such-pane`); + expect(gone.status).toBe(404); + expect(typeof ((await gone.json()) as ApiError).error.code).toBe("string"); + const relative = await fetch(`${base()}/api/workspace/diff/files?pane_id=no-such-pane&directory=sub`); + expect(relative.status).toBe(400); + expect(((await relative.json()) as ApiError).error.code).toBe("invalid_cwd"); + const posted = await fetch(`${base()}/api/workspace/diff/files?${pane}`, { method: "POST", headers: { "x-herdr-machine": "1" } }); + expect(posted.status).toBe(400); + expect(((await posted.json()) as ApiError).error.code).toBe("method_not_allowed"); + }); +}); diff --git a/server/attach-held.contract.test.ts b/server/attach-held.contract.test.ts index 99aca05f1..dd2d9bb6a 100644 --- a/server/attach-held.contract.test.ts +++ b/server/attach-held.contract.test.ts @@ -268,17 +268,18 @@ describe("an attach classified as held", () => { it("is not resumed by the attach's own setup bytes when herdr's refusal comes after them", async () => { const paneId = await pane(); // herdr refuses, then a busy herdr answers late: the attach's own mode setup (mouse - // reporting off, the alternate screen on), a gap longer than the hold, the refusal; then - // the real attach - const late = `printf '\\033[?1006l\\033[?1000l'; sleep 0.2; printf '\\033[?1049h\\033[?1006l\\033[?2031l\\033[?7h'; sleep 0.4; printf '\\033[?1049l\\033[?25h\\033[0 q'; ${HELD}`; + // reporting off, the alternate screen on), a gap longer than the hold, its teardown (modes + // reset, the cursor style restored), another such gap, the refusal; then the real attach. + // herdr writes the teardown and its message apart: a loaded PC can pause it between them. + const late = `printf '\\033[?1006l\\033[?1000l'; sleep 0.2; printf '\\033[?1049h\\033[?1006l\\033[?2031l\\033[?7h'; sleep 0.4; printf '\\033[?1049l\\033[?25h\\033[0 q'; sleep 0.4; ${HELD}`; const herdr = scriptedHerdr([HELD, late, "real"]); await withHerdr(herdr.path, async () => { const client = connect(third.port, paneId); try { await client.open; client.send({ type: "attach", pane_id: paneId, cols: 100, rows: 30 }); - await until(() => herdr.attempts() === 3, "the real attach starts", 5_000); - await until(() => client.state.frames > 0, "the real attach paints", 5_000); + await until(() => herdr.attempts() === 3, "the real attach starts"); + await until(() => client.state.frames > 0, "the real attach paints"); // the late refusal neither resumed the pane nor held it a second time expect(client.state.resumed).toBe(1); expect(client.state.errors).toEqual(["attach_held"]); diff --git a/server/auth.test.ts b/server/auth.test.ts index 79489b08f..15c402092 100644 --- a/server/auth.test.ts +++ b/server/auth.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { authClient, forgetAuthAttempts, handleAuthRequest, isSecureRequest, presentedToken, recordPresentedTokenFailure } from "./auth.ts"; +import { AUTH_CLIENTS_MAX, authClient, forgetAuthAttempts, handleAuthRequest, isSecureRequest, presentedToken, recordPresentedTokenFailure, sharedFailuresSize } from "./auth.ts"; import { sameOrigin } from "./machine-security.ts"; const dir = mkdtempSync(join(tmpdir(), "herdr-auth-")); @@ -121,6 +121,30 @@ describe("the budget visitors behind one proxy share", () => { expect(presentedToken(cookie(TOKEN), TOKEN, "100.64.0.31 via 127.0.0.1")).toBe("match"); } finally { setSystemTime(); } }); + + it("regression (fails on main): bounds shared failure keys after many distinct peers", () => { + for (let peer = 0; peer < AUTH_CLIENTS_MAX * 20; peer += 1) { + const loopbackPeer = `127.${Math.floor(peer / 65536)}.${Math.floor((peer % 65536) / 256)}.${peer % 256}`; + const client = authClient(loopbackPeer, new Headers({ "x-forwarded-for": `100.64.${Math.floor(peer / 256)}.${peer % 256}` })); + recordPresentedTokenFailure(client); + } + expect(sharedFailuresSize()).toBeLessThanOrEqual(AUTH_CLIENTS_MAX); + }); + + it("keeps the hold of the proxy that failed last when full, evicting the one quiet longest", () => { + const start = Date.now(); + try { + setSystemTime(start); + for (let attempt = 0; attempt < 49; attempt += 1) recordPresentedTokenFailure(`first-${attempt} via 127.0.0.1`); + for (let peer = 1; peer < AUTH_CLIENTS_MAX; peer += 1) recordPresentedTokenFailure(`fill via 127.1.${Math.floor(peer / 256)}.${peer % 256}`); + // the oldest key fails again: its hold starts, and it is now the newest + recordPresentedTokenFailure("last via 127.0.0.1"); + expect(presentedToken(cookie(TOKEN), TOKEN, "unknown via 127.0.0.1")).toBe("held"); + recordPresentedTokenFailure("overflow via 127.2.0.1"); + expect(sharedFailuresSize()).toBe(AUTH_CLIENTS_MAX); + expect(presentedToken(cookie(TOKEN), TOKEN, "unknown via 127.0.0.1")).toBe("held"); + } finally { setSystemTime(); } + }); }); describe("isSecureRequest", () => { @@ -130,6 +154,7 @@ describe("isSecureRequest", () => { expect(isSecureRequest(new Request("http://host/api/auth"))).toBe(false); expect(isSecureRequest(new Request("http://host/api/auth", { headers: { "x-forwarded-proto": "http" } }))).toBe(false); }); + }); describe("sameOrigin", () => { @@ -174,4 +199,4 @@ describe("sameOrigin", () => { expect(sameOrigin(new Request("http://host/api/machines/local/workspace/create", { method: "POST", headers: page }))).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/server/auth.ts b/server/auth.ts index f3ab816d2..51b0a3850 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -39,7 +39,7 @@ const AUTH_FAILURE_BUDGET = 5; const AUTH_BACKOFF_MS = 1_000; const AUTH_BACKOFF_MAX_MS = 60_000; /** addresses remembered; past the cap the ones whose wait is over are dropped first */ -const AUTH_CLIENTS_MAX = 1024; +export const AUTH_CLIENTS_MAX = 1024; const authAttempts = new Map(); const BEARER_PREFIX = "bearer "; const encoder = new TextEncoder(); @@ -148,6 +148,9 @@ type Channel = "sign-in" | "presented"; const SHARED_FAILURES = AUTH_FAILURE_BUDGET * 10; const SHARED_WINDOW_MS = 10 * 60_000; const sharedFailures = new Map(); +export function sharedFailuresSize(): number { + return sharedFailures.size; +} /** addresses that presented the right token, and when: the shared hold passes them */ const ADMITTED_FOR_MS = 30 * 24 * 60 * 60_000; const admitted = new Map(); @@ -185,7 +188,19 @@ function recordFailure(channel: Channel, client: string | null): void { if (key === null) return; const recent = (sharedFailures.get(key) ?? []).filter((time) => Date.now() - time < SHARED_WINDOW_MS); recent.push(Date.now()); + // re-inserted, so a full map evicts the proxy that failed longest ago, never one failing now + sharedFailures.delete(key); sharedFailures.set(key, recent.slice(-SHARED_FAILURES)); + trimSharedFailures(); +} + +/** The shared proxy budgets cannot retain more distinct addresses than the per-client budgets. */ +function trimSharedFailures(): void { + for (const [address, failures] of sharedFailures) { + if (failures.every((time) => Date.now() - time >= SHARED_WINDOW_MS)) sharedFailures.delete(address); + } + if (sharedFailures.size <= AUTH_CLIENTS_MAX) return; + while (sharedFailures.size > AUTH_CLIENTS_MAX) sharedFailures.delete(sharedFailures.keys().next().value!); } /** The right token from `client`: its own waits are spent, and the shared hold passes it from now on. */ diff --git a/server/chat-read-timeout.test.ts b/server/chat-read-timeout.test.ts new file mode 100644 index 000000000..5ae0bd8c1 --- /dev/null +++ b/server/chat-read-timeout.test.ts @@ -0,0 +1,103 @@ +import { expect, it } from "bun:test"; +import { createServer as createSocketServer, type Socket } from "node:net"; +import { get } from "node:http"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "./index.ts"; +import { forgetTranscriptState } from "./conversation.ts"; +import { socketAddress } from "./herdr/client.ts"; + +/** One HTTP attempt: fetch may retry a connection that Bun's idle timer closed. */ +function read(url: string): Promise<{ status: number; body: any }> { + return new Promise((resolve, reject) => { + const request = get(url, { agent: false }, (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { body += chunk; }); + response.on("error", reject); + response.on("end", () => { + try { resolve({ status: response.statusCode!, body: JSON.parse(body) }); } + catch (error) { reject(error); } + }); + }); + request.on("error", reject); + request.setTimeout(25_000, () => request.destroy(new Error("chat response did not finish"))); + }); +} + +it("keeps chat history readable while valid sequential herdr calls outlast HTTP inactivity", async () => { + const root = mkdtempSync(join(tmpdir(), "herdr-chat-timeout-")); + const previousSocket = process.env.HERDR_SOCKET; + const socketPath = join(root, "herdr.sock"); + const codexHome = join(root, "codex"); + const path = join(codexHome, "sessions", "rollout.jsonl"); + mkdirSync(join(codexHome, "sessions"), { recursive: true }); + writeFileSync(path, [ + { type: "session_meta", payload: { id: "00000000-0000-4000-8000-000000000001", cwd: "/synthetic/work" } }, + ...Array.from({ length: 120 }, (_, index) => [ + { type: "event_msg", payload: { type: "task_started", turn_id: `turn-${index}` } }, + { type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: `Fictional question ${index}` }] } }, + { type: "response_item", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: `Fictional answer ${index}` }] } }, + { type: "event_msg", payload: { type: "task_complete", turn_id: `turn-${index}` } }, + ]).flat(), + ].map((entry) => JSON.stringify(entry)).join("\n") + "\n"); + const pane = { pane_id: "fixture", cwd: "/synthetic/work", agent: "codex", agent_status: "working", agent_session: { agent: "codex", kind: "path", value: path } }; + const sockets = new Set(); + const timers = new Set>(); + let slow = false; + // Only the external daemon is a stand-in. The HTTP handler, RPC client and transcript reader run unchanged. + const daemon = createSocketServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + if (!buffer.includes("\n")) return; + const { id, method } = JSON.parse(buffer.split("\n")[0]!); + buffer = ""; + // A lost daemon reply must still reach the caller as the RPC's bounded error. + if (method === "pane.read") return; + const result = method === "session.snapshot" ? { snapshot: { panes: [pane], agents: [], workspaces: [], tabs: [], layouts: [] } } + : method === "agent.get" ? { agent: pane } : {}; + const answer = () => { if (!socket.destroyed) socket.write(JSON.stringify({ id, result }) + "\n"); }; + if (slow && (method === "session.snapshot" || method === "agent.get")) { + // Simulate daemon work, each reply inside its own 10 s RPC deadline; together over Bun's default 10 s. + const timer = setTimeout(() => { timers.delete(timer); answer(); }, 6_000); + timers.add(timer); + } else answer(); + }); + }); + let app: ReturnType | undefined; + try { + await new Promise((resolve, reject) => { daemon.once("error", reject); daemon.listen(socketAddress(socketPath), resolve); }); + process.env.HERDR_SOCKET = socketPath; + forgetTranscriptState(); + app = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: join(root, "state"), codexHome, herdrProfiles: async () => [], registerBridge: false, tailscaleOwner: null }); + const base = `http://127.0.0.1:${app.port}/api/pane/`; + const initial = await read(`${base}conversation?pane_id=fixture`); + expect(initial.status).toBe(200); + expect(initial.body.cursor).toBeString(); + slow = true; + const [newest, history, stalled] = await Promise.all([ + read(`${base.replace("/api/pane/", "/api/machines/local/pane/")}conversation?pane_id=fixture`), + read(`${base}conversation?pane_id=fixture&before=${encodeURIComponent(initial.body.cursor)}`), + read(`${base}read?pane_id=fixture&source=recent`), + ]); + expect(newest.status).toBe(200); + expect(newest.body.turns.at(-1).parts).toContainEqual({ kind: "text", text: "Fictional answer 119" }); + expect(history.status).toBe(200); + expect(history.body.turns.at(-1).parts).toContainEqual({ kind: "text", text: "Fictional answer 69" }); + expect(stalled.status).toBe(502); + expect(stalled.body.error.code).toBe("timeout"); + } finally { + app?.stop(); + for (const timer of timers) clearTimeout(timer); + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => daemon.close(() => resolve())); + forgetTranscriptState(); + if (previousSocket === undefined) delete process.env.HERDR_SOCKET; + else process.env.HERDR_SOCKET = previousSocket; + rmSync(root, { recursive: true, force: true }); + } +}, 30_000); diff --git a/server/claude-session.contract.test.ts b/server/claude-session.contract.test.ts index 17ce285b5..d26ce0469 100644 --- a/server/claude-session.contract.test.ts +++ b/server/claude-session.contract.test.ts @@ -159,6 +159,12 @@ afterAll(async () => { rmSync(root, { recursive: true, force: true }); }); +/** Polls `check` for up to 10 s, then asserts it: the server answers what it knows now, and knows more later. */ +async function until(check: () => Promise): Promise { + for (let attempt = 0; attempt < 100 && !(await check()); attempt++) await Bun.sleep(100); + expect(await check()).toBe(true); +} + async function read(paneId: string): Promise { const response = await fetch(`http://127.0.0.1:${server.port}/api/pane/conversation?pane_id=${encodeURIComponent(paneId)}`); expect(response.status).toBe(200); @@ -185,7 +191,6 @@ it.skipIf(!NATIVE)("lists a Claude pane's subagents, counts the running ones and return snapshot.panes.find((entry) => entry.pane_id === paneId)?.background_tasks; }; const frames = await Frames.connect(server.port); - const countedFrame = frames.wait((frame) => frame.type === "pane-status" && frame.pane_id === second.pane && frame.background_tasks === 1); const folder = join(project, SECOND, "subagents"); mkdirSync(folder, { recursive: true }); writeFileSync(join(folder, "agent-a1.meta.json"), JSON.stringify({ agentType: "reviewer", description: "Review the parser", toolUseId: "toolu_a1", requestShape: "background" })); @@ -196,9 +201,13 @@ it.skipIf(!NATIVE)("lists a Claude pane's subagents, counts the running ones and try { const subscribed = await fetch(`${base}/api/push/subscribe`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ subscription: device.subscription, alerts: { input: true, done: "always" } }) }); expect(subscribed.status).toBe(204); - // asked first, before the server has had a snapshot to look for the pane's transcript with: it finds it itself + // asked: the server finds the pane's transcript itself. Its background lookup may already have come up empty + // (herdr busy or late under load), which it does not repeat for a few seconds: the list follows once it looks again, + // as the client's own retries see it + await until(async () => (await tasks(second.pane)).tasks.some((task) => task.id === "a1" && task.status === "running")); expect((await tasks(second.pane)).tasks).toMatchObject([{ id: "a1", status: "running" }]); - await countedFrame; + // from the first frame: the count may have been pushed while the list was polled + await frames.wait((frame) => frame.type === "pane-status" && frame.pane_id === second.pane && frame.background_tasks === 1, 0); expect(await counted(second.pane)).toBe(1); expect(await tasks(second.pane)).toMatchObject({ tasks: [{ id: "a1", title: "Review the parser", category: "reviewer", status: "running" }], runs: [] }); // a pane with no subagents has no count and no list @@ -229,7 +238,7 @@ it.skipIf(!NATIVE)("lists a Claude pane's subagents, counts the running ones and await herdrRpc("pane.report_agent", { pane_id: first.pane, source: "manual", agent: "claude", state: "idle" }); device.stop(); } -}, 15_000); +}, 25_000); /** Each hold owns its pane, transcript and bridge, so running one test never needs an earlier one. */ async function backgroundTurn(taskId: string) { @@ -340,10 +349,6 @@ it.skipIf(!NATIVE)("reads a Claude pane's task list as its plan, and pushes its const { snapshot } = await (await fetch(`${base}/api/session`)).json() as { snapshot: { panes: { pane_id: string; plan?: unknown }[] } }; return snapshot.panes.find((entry) => entry.pane_id === paneId)?.plan; }; - const until = async (check: () => Promise): Promise => { - for (let attempt = 0; attempt < 100 && !(await check()); attempt++) await Bun.sleep(100); - expect(await check()).toBe(true); - }; const path = join(project, `${FIRST}.jsonl`); const call = (id: string, name: string, input: unknown, answer: unknown, content: string): string => [ { type: "assistant", timestamp: new Date().toISOString(), message: { role: "assistant", content: [{ type: "tool_use", id, name, input }] } }, diff --git a/server/claude-store.test.ts b/server/claude-store.test.ts index 0a9039418..63c1dd86e 100644 --- a/server/claude-store.test.ts +++ b/server/claude-store.test.ts @@ -210,6 +210,70 @@ describe("claudeTranscriptFile", () => { expect(await claudeTranscriptFile(dir, SESSION, ["/w/project"])).toBe(original); }); + describe("a /resume of a background session", () => { + const PARKED = "e4159264-2222-4222-8222-222222222222"; + const BG = "8b80137e-f8fe-4aaa-8bbb-cccccccccccc"; + const resumed = (folder: string, jobId = "8b80137e") => { + const original = join(folder, `${SESSION}.jsonl`); + writeFileSync(original, [ + { type: "user", message: { content: "/resume" } }, + { type: "user", message: { content: `Opening \"title\", running in the background (${jobId})` } }, + { type: "continued-in", sessionId: SESSION, continuedInSessionId: PARKED }, + ].map((entry) => JSON.stringify(entry)).join("\n")); + return original; + }; + + it("follows the output's job id to that job's transcript (regression: fails on main)", async () => { + const { home: dir, projects } = home(); + const folder = join(projects, claudeProjectDir("/w/project")); + mkdirSync(folder, { recursive: true }); + resumed(folder); + const job = join(folder, `${BG}.jsonl`); + writeFileSync(job, JSON.stringify({ type: "user", message: { content: "real conversation prompt" } })); + expect(await claudeTranscriptFile(dir, SESSION, ["/w/project"])).toBe(job); + }); + + it("keeps the named transcript when no file, or more than one, matches the job id (control)", async () => { + const { home: dir, projects } = home(); + const folder = join(projects, claudeProjectDir("/w/project")); + mkdirSync(folder, { recursive: true }); + const original = resumed(folder); + expect(await claudeTranscriptFile(dir, SESSION, ["/w/project"])).toBe(original); + writeFileSync(join(folder, `${BG}.jsonl`), "{}"); + writeFileSync(join(folder, "8b80137e-0000-4000-8000-000000000000.jsonl"), "{}"); + expect(await claudeTranscriptFile(dir, SESSION, ["/w/project"])).toBe(original); + }); + + it("ignores an earlier /resume's job id when the active continuation has no job output (regression)", async () => { + const { home: dir, projects } = home(); + const folder = join(projects, claudeProjectDir("/w/project")); + mkdirSync(folder, { recursive: true }); + const original = join(folder, `${SESSION}.jsonl`); + writeFileSync(original, [ + { type: "user", message: { content: "Opening, running in the background (8b80137e)" } }, + { type: "continued-in", sessionId: SESSION, continuedInSessionId: PARKED }, + { type: "continued-in", sessionId: SESSION, continuedInSessionId: PARKED }, + ].map((entry) => JSON.stringify(entry)).join("\n")); + writeFileSync(join(folder, `${BG}.jsonl`), JSON.stringify({ type: "user", message: { content: "earlier job" } })); + expect(await claudeTranscriptFile(dir, SESSION, ["/w/project"])).toBe(original); + }); + + it("checks the job's state.json when it names a transcript (regression: fails on main; mismatch is a control)", async () => { + const { home: dir, projects } = home(); + const folder = join(projects, claudeProjectDir("/w/project")); + mkdirSync(folder, { recursive: true }); + const original = resumed(folder); + const job = join(folder, `${BG}.jsonl`); + writeFileSync(job, "{}"); + const state = join(dir, ".claude", "jobs", "8b80137e"); + mkdirSync(state, { recursive: true }); + writeFileSync(join(state, "state.json"), JSON.stringify({ linkScanPath: join(folder, "other.jsonl") })); + expect(await claudeTranscriptFile(dir, SESSION, ["/w/project"])).toBe(original); + writeFileSync(join(state, "state.json"), JSON.stringify({ linkScanPath: job })); + expect(await claudeTranscriptFile(dir, SESSION, ["/w/project"])).toBe(job); + }); + }); + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)("returns an unreadable transcript for its reader to report", async () => { const { home: dir, projects } = home(); const folder = join(projects, claudeProjectDir("/w/project")); diff --git a/server/claude-store.ts b/server/claude-store.ts index 7d2637769..b0065ff98 100644 --- a/server/claude-store.ts +++ b/server/claude-store.ts @@ -15,7 +15,7 @@ import { constants } from "node:fs"; import { open, readFile, readdir, stat } from "node:fs/promises"; -import nodePath, { isAbsolute, join, type PlatformPath } from "node:path"; +import nodePath, { dirname, isAbsolute, join, type PlatformPath } from "node:path"; import { directoryKey, withoutVerbatimPrefix } from "./codex.ts"; import { recentProcessTable } from "./gjc-runtime.ts"; import type { ProcessRow } from "./windows-processes.ts"; @@ -191,8 +191,7 @@ const CONTINUED_TAIL_BYTES = 64 * 1024; * user or assistant turn after it. Only the file's tail is read, since this runs on every lookup. * A file that cannot be read names no continuation: its own reader reports the failure. */ -async function continuedInSession(path: string): Promise { - let text: string; +async function transcriptTail(path: string): Promise { try { const file = await open(path, constants.O_RDONLY); try { @@ -200,15 +199,20 @@ async function continuedInSession(path: string): Promise { const start = Math.max(0, size - CONTINUED_TAIL_BYTES); const buffer = Buffer.alloc(size - start); const { bytesRead } = await file.read(buffer, 0, buffer.length, start); - text = buffer.subarray(0, bytesRead).toString("utf8"); + const text = buffer.subarray(0, bytesRead).toString("utf8"); // a tail that starts mid-line drops that partial line - if (start > 0) text = text.slice(text.indexOf("\n") + 1); + return start > 0 ? text.slice(text.indexOf("\n") + 1) : text; } finally { await file.close(); } } catch { return null; } +} + +async function continuedInSession(path: string): Promise { + const text = await transcriptTail(path); + if (text === null) return null; let continued: string | null = null; for (const line of text.split("\n")) { if (!line.includes('"type"')) continue; @@ -223,6 +227,43 @@ async function continuedInSession(path: string): Promise { return continued; } +/** + * `/resume` of a session running in the background parks the pane on a continuation that never + * gets a transcript; the conversation lives in the job's own `<8 hex>-*.jsonl` of the same project, + * whose id the command prints as `running in the background (<8 hex>)`. Only an unambiguous + * single match is taken, and the job's state.json, when it names a transcript, must agree. + */ +async function backgroundJobTranscript(path: string, configDir: string): Promise { + const text = await transcriptTail(path); + if (text === null) return null; + // each continued-in record claims only the output printed since the previous record, so an + // earlier /resume's job id never names a later continuation + let pending: string | undefined; + let id: string | undefined; + for (const line of text.split("\n")) { + if (line.includes('"type":"continued-in"')) { + id = pending; + pending = undefined; + } else { + pending = [...line.matchAll(/running in the background \(([0-9a-f]{8})\)/g)].at(-1)?.[1] ?? pending; + } + } + if (id === undefined) return null; + let matches: string[]; + try { + matches = (await readdir(dirname(path))).filter((name) => name.startsWith(`${id}-`) && name.endsWith(".jsonl")); + } catch { return null; } + if (matches.length !== 1) return null; + const job = join(dirname(path), matches[0]!); + try { + const state = JSON.parse(await readFile(join(configDir, "jobs", id, "state.json"), "utf8")) as { linkScanPath?: unknown }; + if (typeof state.linkScanPath === "string" && state.linkScanPath !== job) return null; + } catch (error) { + if (!(error instanceof SyntaxError || absent(error))) return null; + } + return await isFile(job) ? job : null; +} + export function forgetClaudeSessions(): void { found.clear(); processDirs.clear(); @@ -350,8 +391,9 @@ export async function claudeTranscriptFile(home: string, session: string, cwds: const next = await continuedInSession(path); if (next === null || seen.has(next)) return path; seen.add(next); - // a continuation whose file is not there keeps the transcript that named it - const nextPath = await locate(next); + // a continuation whose file is not there keeps the transcript that named it, unless a + // /resume of a background session names that job's own transcript + const nextPath = await locate(next) ?? await backgroundJobTranscript(path, configDir); if (nextPath === null) return path; path = nextPath; } diff --git a/server/claude-turn-boundaries.test.ts b/server/claude-turn-boundaries.test.ts new file mode 100644 index 000000000..99df75758 --- /dev/null +++ b/server/claude-turn-boundaries.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import { parseClaudeTranscript } from "./conversation.ts"; +import { splitTurn } from "../src/lib/workBlocks.ts"; + +const jsonl = (...records: unknown[]) => records.map((record) => JSON.stringify(record)).join("\n"); +const reply = (text: string, timestamp: string) => ({ type: "assistant", timestamp, message: { content: [{ type: "text", text }], stop_reason: "end_turn" } }); + +describe("Claude completed turns", () => { + for (const [name, content] of [ + ["Stop hook feedback", "Stop hook feedback: Verify the fictional catalog."], + ["hook additional context", [{ type: "text", text: "Additional hook context: verify the fictional catalog." }]], + ["task notification", "catalogcompletedCatalog check finished."], + ] as const) { + it(`keeps a completed answer separate when ${name} resumes tool work`, () => { + const turns = parseClaudeTranscript(jsonl( + { type: "user", message: { content: "Check the fictional library." } }, + { type: "assistant", message: { content: [{ type: "text", text: "Checking the catalog." }, { type: "tool_use", id: "read", name: "Read", input: { file_path: "catalog.txt" } }], stop_reason: "tool_use" } }, + { type: "user", message: { content: [{ type: "tool_result", tool_use_id: "read", content: "Three books" }] } }, + { type: "assistant", message: { content: [{ type: "text", text: "The library has three books." }, { type: "text", text: "Two are available." }], stop_reason: "end_turn" } }, + { type: "user", isMeta: true, userType: "external", message: { role: "user", content } }, + { type: "system", subtype: "stop_hook_summary" }, + { type: "assistant", message: { content: [{ type: "text", text: "Verifying the catalog." }, { type: "tool_use", id: "verify", name: "Read", input: { file_path: "catalog.txt" } }], stop_reason: "tool_use" } }, + { type: "user", message: { content: [{ type: "tool_result", tool_use_id: "verify", content: "Three books confirmed" }] } }, + reply("Verified.", "2026-01-01T12:02:00Z"), + )); + expect(turns.map((turn) => turn.role)).toEqual(["user", "assistant", "assistant"]); + expect(turns.slice(1).map((turn) => splitTurn(turn.parts).answer.map((part) => part.text))).toEqual([["The library has three books.", "Two are available."], ["Verified."]]); + expect(turns.slice(1).map((turn) => splitTurn(turn.parts).work.map((part) => part.kind === "text" ? part.text : part.kind))).toEqual([["Checking the catalog.", "tool"], ["Verifying the catalog.", "tool"]]); + expect(turns.slice(1).flatMap((turn) => turn.parts.filter((part) => part.kind === "tool").map((part) => part.output))).toEqual(["Three books", "Three books confirmed"]); + }); + } + + it("keeps replies and their times separate across background notifications", () => { + const turns = parseClaudeTranscript(jsonl( + reply("The catalog has three books.", "2026-01-01T12:00:00Z"), + { type: "user", message: { content: "catalogcompletedCatalog check finished." } }, + { type: "assistant", timestamp: "2026-01-01T12:01:00Z", message: { content: [{ type: "text", text: "Checking availability." }, { type: "tool_use", id: "check", name: "Bash", input: { command: "true" } }], stop_reason: "tool_use" } }, + { type: "user", message: { content: [{ type: "tool_result", tool_use_id: "check", content: "Two available" }] } }, + reply("Two books are available.", "2026-01-01T12:02:00Z"), + { type: "user", isMeta: true, userType: "external", message: { content: "Background command catalog-check failed with exit code 124" } }, + reply("The background check timed out.", "2026-01-01T12:03:00Z"), + )); + expect(turns.map((turn) => turn.role)).toEqual(["assistant", "assistant", "assistant"]); + expect(turns.map((turn) => splitTurn(turn.parts).answer.map((part) => part.text))).toEqual([["The catalog has three books."], ["Two books are available."], ["The background check timed out."]]); + expect(turns.map((turn) => turn.end_ts)).toEqual(["2026-01-01T12:00:00Z", "2026-01-01T12:02:00Z", "2026-01-01T12:03:00Z"]); + expect(splitTurn(turns[1]!.parts).work.map((part) => part.kind === "text" ? part.text : part.kind)).toEqual(["Checking availability.", "tool"]); + expect(splitTurn(turns[1]!.parts).work.find((part) => part.kind === "tool")).toMatchObject({ output: "Two available" }); + }); +}); diff --git a/server/codex-binding.contract.test.ts b/server/codex-binding.contract.test.ts index f45c6757b..98a3f6fcc 100644 --- a/server/codex-binding.contract.test.ts +++ b/server/codex-binding.contract.test.ts @@ -58,8 +58,8 @@ const codexRunning = async (paneId: string, not?: string): Promise => { throw new Error(`no codex process in ${paneId}`); }; -const pane = async (label: string, command: string): Promise => { - const created = await workspaceCreate({ cwd: root, label: `herdr-web-ui-test-codex-binding-${label}` }); +const pane = async (label: string, command: string, cwd = root): Promise => { + const created = await workspaceCreate({ cwd, label: `herdr-web-ui-test-codex-binding-${label}` }); workspaces.push(created.workspace.workspace_id); const paneId = created.root_pane.pane_id; await herdrRpc("pane.send_text", { pane_id: paneId, text: `${command}\n` }); @@ -128,7 +128,7 @@ beforeAll(() => { // `--say NAME` shows the question typed and its answer; `--quote NAME` only the answer, as // pasted; `--both NAME` the question and answer of NAME and the matched thread's answer too; // THEN=NAME shows NAME's answers after the flood - writeFileSync(script, `#!/bin/sh\nname="\${2:-matched}"\n[ "$1" = --say ] || [ "$1" = --both ] && echo "› question for $name"\n[ "$1" = --say ] || [ "$1" = --quote ] || [ "$1" = --both ] && cat "$(dirname "$0")/$name.txt"\n[ "$1" = --both ] && cat "$(dirname "$0")/matched.txt"\nsleep "\${FLOOD_AFTER:-600}"\nseq 1 600\n[ -n "$THEN" ] && cat "$(dirname "$0")/$THEN.txt"\nsleep 600\n`); + writeFileSync(script, `#!/bin/sh\n[ -n "$SCREEN_FILE" ] && cat "$SCREEN_FILE"\nname="\${2:-matched}"\n[ "$1" = --say ] || [ "$1" = --both ] && echo "› question for $name"\n[ "$1" = --say ] || [ "$1" = --quote ] || [ "$1" = --both ] && cat "$(dirname "$0")/$name.txt"\n[ "$1" = --both ] && cat "$(dirname "$0")/matched.txt"\nsleep "\${FLOOD_AFTER:-600}"\nseq 1 600\n[ -n "$THEN" ] && cat "$(dirname "$0")/$THEN.txt"\nsleep 600\n`); chmodSync(script, 0o755); server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: join(root, "push"), codexHome }); }); @@ -139,6 +139,38 @@ afterAll(async () => { rmSync(root, { recursive: true, force: true }); }); +it("reads a literal short first answer through the conversation endpoint (regression)", async () => { + const cwd = join(root, "short-first"); + mkdirSync(cwd); + const id = "01a0c7a1-56d9-7e20-9f08-f7a2d973bd42"; + const path = join(codexHome, "sessions", `rollout-2026-10-14T00-00-00-${id}.jsonl`); + const prompt = "Reply with only the number 42."; + writeFileSync(path, [ + { type: "session_meta", payload: { id, cwd } }, + { type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: prompt }] } }, + { type: "response_item", payload: { type: "message", role: "assistant", phase: "final_answer", content: [{ type: "output_text", text: "42" }] } }, + ].map((entry) => JSON.stringify(entry)).join("\n") + "\n"); + const screen = join(cwd, "screen.txt"); + writeFileSync(screen, `OpenAI Codex (v0.162.1)\n\n• Model changed to gpt-5\n\n› ${prompt}\n\n• 42\n\n› \nSHORT_SCREEN_READY\n`); + const opened = workspaces.length; + const paneId = await pane("short-first", `SCREEN_FILE=${screen} ${join(root, "bin", "codex")}`, cwd); + const db = new Database(join(codexHome, "state_5.sqlite")); + const created = Math.ceil(Date.now() / 1000) + 2; + db.query("INSERT INTO threads VALUES (?, ?, ?, 0, NULL, ?, ?, 'cli', ?)").run(id, path, cwd, created, created, prompt); + db.close(); + try { + await onScreen(paneId, "SHORT_SCREEN_READY"); + const conversation = await read(paneId); + expect(conversation.source).toBe("codex-transcript"); // regression + expect(lastAnswer(conversation)).toBe("42"); // regression + } finally { + const db = new Database(join(codexHome, "state_5.sqlite")); + db.query("DELETE FROM threads WHERE id = ?").run(id); + db.close(); + for (const workspace of workspaces.splice(opened)) await workspaceClose(workspace); + } +}); + it("reads the thread a Codex TUI was resumed on while none of its answers is on screen, until a newer thread begins", async () => { const paneId = await pane("resumed", `${join(root, "bin", "codex")} resume ${threads.resumed}`); const conversation = await read(paneId); diff --git a/server/codex-processes.test.ts b/server/codex-processes.test.ts index 97b9b7f41..4099a2b71 100644 --- a/server/codex-processes.test.ts +++ b/server/codex-processes.test.ts @@ -251,7 +251,7 @@ shortIt("handles a Windows stored cwd prefix using a Linux fixture process", asy expect(await codexTranscriptPath(root, cwd, home, [fixturePane(root, cwd)])).toBe(path); }); -shortIt("requires a submitted first prompt and substantive answer, not a composer or quote", async () => { +shortIt("requires a submitted first prompt and exact answer, not a composer or quote (control)", async () => { await shortSession(); for (const shown of [ `OpenAI Codex (v1.0)\n› ${shortPrompt}`, @@ -266,7 +266,10 @@ shortIt("requires a submitted first prompt and substantive answer, not a compose } shortRollout(path, shortPrompt, "Ready"); screen = shortScreen.replace(shortAnswer, "Ready"); - expect(await resolve()).toBeNull(); + expect(await resolve()).toBe(path); // regression: a tiny but exact answer is sufficient + shortRollout(path, shortPrompt, ""); + screen = shortScreen.replace(shortAnswer, ""); + expect(await resolve()).toBeNull(); // control: no answer is not a submitted exchange }); shortIt("retains older resumed later turns as competitors to a new first prompt", async () => { diff --git a/server/codex.test.ts b/server/codex.test.ts index 4a5d0d580..931e00d1f 100644 --- a/server/codex.test.ts +++ b/server/codex.test.ts @@ -816,13 +816,28 @@ describe("Codex submitted first exchange", () => { expect(matchCodexFirstExchange(screen, [row(), row("other")], started)).toBeNull(); }); - it("does not admit pre-start timestamps, missing prompt metadata or tiny answers", () => { + it("does not admit pre-start timestamps or missing prompt metadata (control)", () => { for (const time of [started - 1, Infinity, NaN]) { expect(matchCodexFirstExchange(screen, [row("new", time)], started)).toBeNull(); } for (const time of [Infinity, NaN]) expect(matchCodexFirstExchange(screen, [row()], time)).toBeNull(); expect(matchCodexFirstExchange(screen, [{ ...row(), firstUserMessage: "" }], started)).toBeNull(); - expect(matchCodexFirstExchange(screen.replace(answer, "Ready"), [row("new", started, prompt, "Ready")], started)).toBeNull(); + }); + + it("matches tiny first answers only with the exact submitted exchange (regression)", () => { + for (const reply of ["42", "Ready", "Y"]) { + expect(matchCodexFirstExchange(screen.replace(answer, reply), [row("new", started, prompt, reply)], started)).toBe("new"); + } + expect(matchCodexFirstExchange(screen.replace(answer, ""), [row("new", started, prompt, "")], started)).toBeNull(); // control + expect(matchCodexFirstExchange(screen.replace(answer, "42"), [ + row("new", started, prompt, "42"), row("old", started - 1, prompt, "42"), + ], started)).toBeNull(); // control: competing evidence + }); + + it("skips known Codex notices before the submitted prompt (regression)", () => { + for (const notice of ["Model changed to gpt-5", "Unrecognized command '/wat'", "Working directory changed to /tmp"]) { + expect(matchCodexFirstExchange(screen.replace(`› ${prompt}`, `• ${notice}\n\n› ${prompt}`), [row()], started)).toBe("new"); + } }); }); diff --git a/server/codex.ts b/server/codex.ts index 84c8dd7ec..066177772 100644 --- a/server/codex.ts +++ b/server/codex.ts @@ -11,7 +11,7 @@ import { processStartedAt } from "./process-start.ts"; import { codexImageParts } from "./codex-images.ts"; import { codexReadCall, codexReadSkills, selectedSkill } from "./skill-activity.ts"; import { trimOutput } from "./tool-output.ts"; -import { herdrRpc, paneRead, sessionSnapshot } from "./herdr/client.ts"; +import { agentGet, paneProcessInfo, paneRead, sessionSnapshot } from "./herdr/client.ts"; type RecordValue = Record; const record = (value: unknown): RecordValue => value !== null && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {}; @@ -967,6 +967,7 @@ function displayedFirstExchange(screen: string): CodexExchange | null { let promptAt = -1; for (let index = 0; index < lines.length; index++) { const line = lines[index] ?? ""; + if (/^• (?:Model changed|Unrecognized command|Working directory changed)(?:[ \t]|$)/.test(line)) continue; if (/^•(?:[ \t]|$)/.test(line)) return null; if (/^›[ \t]+/.test(line)) { promptAt = index; break; } } @@ -985,12 +986,12 @@ function displayedFirstExchange(screen: string): CodexExchange | null { return { user, assistant: response }; } -function matchingCodexExchanges(screen: string, candidates: readonly CodexFirstExchangeCandidate[]): { candidate: CodexFirstExchangeCandidate; index: number }[] { +function matchingCodexExchanges(screen: string, candidates: readonly CodexFirstExchangeCandidate[], answerFloor: number): { candidate: CodexFirstExchangeCandidate; index: number }[] { const shown = displayedFirstExchange(screen); if (shown === null) return []; const prompt = normalizePrompt(shown.user); const answer = normalizePrompt(shown.assistant); - if (normalizeDisplay(prompt).length < 8 || normalizeDisplay(answer).length < 16) return []; + if (normalizeDisplay(prompt).length < 8 || normalizeDisplay(answer).length < answerFloor) return []; const matches: { candidate: CodexFirstExchangeCandidate; index: number }[] = []; for (const candidate of candidates) { for (const [index, exchange] of codexExchanges(candidate.text).entries()) { @@ -1014,7 +1015,7 @@ export function matchCodexFirstExchange( if (!Number.isFinite(startedAtMs)) return null; if (candidates.filter((candidate) => typeof candidate.createdAtMs === "number" && candidate.createdAtMs >= startedAtMs).length !== 1) return null; - const matches = matchingCodexExchanges(screen, candidates); + const matches = matchingCodexExchanges(screen, candidates, 1); if (matches.length !== 1) return null; const match = matches[0]!; const first = codexExchanges(match.candidate.text)[0]; @@ -1089,10 +1090,10 @@ const VALUELESS_OPTIONS = new Set([ /** A pane's Codex executables (or interpreter scripts), and their pids as one key. */ async function codexProcessesOf(paneId: string): Promise<{ list: { pid: number; argv?: string[] }[]; key: string }> { - const processInfo = await herdrRpc<{ process_info?: { foreground_processes?: { pid: number; argv?: string[] }[] } }>( - "pane.process_info", { pane_id: paneId }, - ); - const list = (processInfo.process_info?.foreground_processes ?? []) + const processInfo = await paneProcessInfo(paneId); + // herdr's schema allows `argv: null`; this reader has always read argv as absent or a list + // (the cast is the inline result type herdrRpc was given before the typed seam) + const list = ((processInfo?.foreground_processes ?? []) as { pid: number; argv?: string[] }[]) .map((foreground) => { if (foreground.argv?.length || process.platform !== "linux") return foreground; // herdr can report only pid/name. Read this process, never infer argv from @@ -1326,8 +1327,7 @@ async function openRollouts(paneId: string, home: string): Promise<{ codexProces * matches the pane again. */ export async function codexOpenRollout(paneId: string, cwd: string | null | undefined, home = defaultCodexHome()): Promise { - const info = await herdrRpc<{ agent: { agent_session?: { kind?: string; value?: string } } }>("agent.get", { target: paneId }); - const session = info.agent.agent_session; + const session = (await agentGet(paneId)).agent_session; if (session?.kind === "path" && session.value) return codexRolloutPath(session.value, home); const { processes, open } = await openRollouts(paneId, home); if (open.size === 1) return [...open][0]!; @@ -1360,8 +1360,7 @@ export async function codexTranscriptPath(paneId: string, cwd: string, home = de const open = new Set(panes.map((pane) => pane.pane_id)); for (const pane of [...boundRollouts.keys()]) if (!open.has(pane)) boundRollouts.delete(pane); } - const info = await herdrRpc<{ agent: { agent_session?: { kind?: string; value?: string } } }>("agent.get", { target: paneId }); - const session = info.agent.agent_session; + const session = (await agentGet(paneId)).agent_session; if (session?.kind === "path" && session.value) return codexRolloutPath(session.value, home); const { codexProcesses, processes, open } = await openRollouts(paneId, home); diff --git a/server/commands.test.ts b/server/commands.test.ts index 6ba8baf5e..87b6bbbe0 100644 --- a/server/commands.test.ts +++ b/server/commands.test.ts @@ -49,6 +49,68 @@ describe("paneCommands", () => { describe("skills and plugins", () => { const write = (path: string, text: string) => { mkdirSync(join(path, ".."), { recursive: true }); writeFileSync(path, text); }; + it("offers linked skills and commands while skipping broken links and cycles", () => { + const home = temp("linked-home-"); + const cwd = temp("linked-project-"); + const shared = temp("linked-resources-"); + write(join(shared, "skill", "SKILL.md"), "---\ndescription: Shared skill\n---\n"); + write(join(shared, "review.md"), "Shared review\n"); + write(join(shared, "team", "check.md"), "Shared check\n"); + symlinkSync(".", join(shared, "team", "cycle"), "dir"); + const plugin = join(home, "plugin"); + for (const root of [join(home, ".claude"), join(cwd, ".claude"), join(home, ".codex"), join(cwd, ".codex"), join(home, ".agents"), join(home, ".pi", "agent"), plugin]) { + const skills = join(root, "skills"); + mkdirSync(skills, { recursive: true }); + symlinkSync(join(shared, "skill"), join(skills, "linked"), "dir"); + symlinkSync(join(shared, "missing"), join(skills, "broken"), "dir"); + symlinkSync("cycle", join(skills, "cycle"), "dir"); + } + for (const commands of [join(home, ".claude", "commands"), join(cwd, ".claude", "commands"), join(home, ".codex", "prompts"), join(plugin, "commands")]) { + mkdirSync(commands, { recursive: true }); + symlinkSync(join(shared, "review.md"), join(commands, "review.md")); + symlinkSync(join(shared, "missing.md"), join(commands, "broken.md")); + symlinkSync("cycle.md", join(commands, "cycle.md")); + symlinkSync(join(shared, "team"), join(commands, "team"), "dir"); + symlinkSync(join(shared, "team"), join(commands, "alias"), "dir"); + symlinkSync(".", join(commands, "cycle"), "dir"); + } + write(join(home, ".claude", "plugins", "installed_plugins.json"), JSON.stringify({ plugins: { "tools@market": [{ installPath: plugin }] } })); + write(join(home, ".claude", "settings.json"), JSON.stringify({ enabledPlugins: { "tools@market": true } })); + + expect(paneCommands("claude", cwd, home).filter((command) => command.source !== "builtin")).toEqual([ + { name: "alias:check", description: "Shared check", source: "project" }, + { name: "alias:check", description: "Shared check", source: "user" }, + { name: "linked", description: "Shared skill", source: "skill" }, + { name: "linked", description: "Shared skill", source: "skill" }, + { name: "review", description: "Shared review", source: "project" }, + { name: "review", description: "Shared review", source: "user" }, + { name: "team:check", description: "Shared check", source: "project" }, + { name: "team:check", description: "Shared check", source: "user" }, + { name: "tools:alias:check", description: "Shared check", source: "plugin" }, + { name: "tools:linked", description: "Shared skill", source: "plugin" }, + { name: "tools:review", description: "Shared review", source: "plugin" }, + { name: "tools:team:check", description: "Shared check", source: "plugin" }, + ]); + expect(paneCommands("codex", cwd, home).filter((command) => command.source !== "builtin")).toEqual([ + { name: "linked", description: "Shared skill", source: "skill", trigger: "$" }, + { name: "linked", description: "Shared skill", source: "skill", trigger: "$" }, + { name: "prompts:alias:check", description: "Shared check", source: "user" }, + { name: "prompts:review", description: "Shared review", source: "user" }, + { name: "prompts:team:check", description: "Shared check", source: "user" }, + ]); + const previousPiAgentDir = process.env["PI_CODING_AGENT_DIR"]; + process.env["PI_CODING_AGENT_DIR"] = join(home, ".pi", "agent"); + try { + expect(paneCommands("pi", null, home).filter((command) => command.source !== "builtin")).toEqual([ + { name: "skill:linked", description: "Shared skill", source: "skill" }, + { name: "skill:linked", description: "Shared skill", source: "skill" }, + ]); + } finally { + if (previousPiAgentDir === undefined) delete process.env["PI_CODING_AGENT_DIR"]; + else process.env["PI_CODING_AGENT_DIR"] = previousPiAgentDir; + } + }); + it("offers Claude's skills, user and project, and the enabled plugins' skills and commands", () => { const home = temp("skills-home-"); const cwd = temp("skills-cwd-"); diff --git a/server/commands.ts b/server/commands.ts index fa530b8f7..e7c8cfd12 100644 --- a/server/commands.ts +++ b/server/commands.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { piAgentDir } from "./pi-models.ts"; import { join, relative, sep } from "node:path"; @@ -65,11 +65,13 @@ function skills(root: string, source: SlashCommand["source"], options: { prefix? if (!existsSync(root)) return []; const result: SlashCommand[] = []; for (const entry of readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory() || entry.name.startsWith(".")) continue; - const file = join(root, entry.name, "SKILL.md"); - if (!existsSync(file)) continue; + if (entry.name.startsWith(".")) continue; + const directory = join(root, entry.name); let markdown: string; - try { markdown = readFileSync(file, "utf8"); } catch { continue; } + try { + if (!statSync(directory).isDirectory()) continue; + markdown = readFileSync(join(directory, "SKILL.md"), "utf8"); + } catch { continue; } const name = skillName(markdown, entry.name); result.push({ name: options.prefix ? `${options.prefix}:${name}` : name, description: description(markdown), source, ...(options.trigger ? { trigger: options.trigger } : {}) }); } @@ -98,15 +100,26 @@ function pluginCommands(home: string): SlashCommand[] { function customCommands(root: string, source: SlashCommand["source"]): SlashCommand[] { if (!existsSync(root)) return []; const result: SlashCommand[] = []; + // Guard only the current ancestry: sibling links can expose different command namespaces. + const ancestors = new Set(); const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (entry.isDirectory()) visit(path); - else if (entry.isFile() && entry.name.endsWith(".md")) { - const relativeName = relative(root, path).slice(0, -3).split(sep).join(":"); - result.push({ name: relativeName, description: description(readFileSync(path, "utf8")), source }); + let canonical: string; + try { canonical = realpathSync(directory); } catch { return; } + if (ancestors.has(canonical)) return; + ancestors.add(canonical); + try { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + try { + const target = statSync(path); + if (target.isDirectory()) visit(path); + else if (target.isFile() && entry.name.endsWith(".md")) { + const relativeName = relative(root, path).slice(0, -3).split(sep).join(":"); + result.push({ name: relativeName, description: description(readFileSync(path, "utf8")), source }); + } + } catch { continue; } } - } + } finally { ancestors.delete(canonical); } }; visit(root); return result; diff --git a/server/conventions.test.ts b/server/conventions.test.ts new file mode 100644 index 000000000..4a2a2be85 --- /dev/null +++ b/server/conventions.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "bun:test"; +import { fail, files, lineOf, read, stripComments } from "../scripts/source-scan.ts"; + +// Server rules from AGENTS.md and server/AGENTS.md that used to be prose only. Each failure says +// what to change; client rules are in src/conventions.test.ts, repo-wide ones in +// scripts/conventions.test.ts. + +/** Server code that ships: not tests, not test stand-ins. */ +const serverCode = (): string[] => files("server/**/*.{ts,mjs,js}").filter((file) => !/\.test\.ts$|\.fake\.ts$/.test(file)); + +/** Every match of `pattern` in each file's code (comments removed), as `file:line: text`. */ +function matches(fileList: string[], pattern: RegExp): { file: string; at: string; index: number; code: string }[] { + return fileList.flatMap((file) => { + const code = stripComments(read(file)); + return [...code.matchAll(new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`))].map((match) => { + const line = lineOf(code, match.index); + return { file, index: match.index, code, at: `${file}:${line}: ${code.split("\n")[line - 1]!.trim().slice(0, 160)}` }; + }); + }); +} + +describe("PTY sidecar boundary", () => { + // node-pty panics Bun (oven-sh/bun#18546): only the Node sidecar loads it. `Bun.resolveSync` + // in server/pty/sidecar.ts only finds the package for Node and is not a load. + const NODE_SIDE = new Set(["server/pty/pty-host.mjs", "server/pty/smoke.mjs"]); + const LOAD = /\bfrom\s*["'](?:@lydell\/)?node-pty[^"']*["']|\bimport\s*\(?\s*["'](?:@lydell\/)?node-pty[^"']*["']|\b(?:require|createRequire\s*\([^()]*(?:\([^()]*\))?[^()]*\))\s*\(\s*["'](?:@lydell\/)?node-pty[^"']*["']/; + + it("loads node-pty only in the Node sidecar", () => { + // this file names the package in its own examples + const code = [...files("{server,src,shared,scripts,site,telemetry}/**/*.{ts,tsx,mjs,cjs,js}"), "vite.config.ts"].filter((file) => file !== "server/conventions.test.ts"); + const loads = matches(code, LOAD); + fail(loads.filter((load) => !NODE_SIDE.has(load.file)).map((load) => load.at), + "node-pty must never load inside Bun (AGENTS.md, Bridge invariants). PTY work goes through the Node sidecar server/pty/pty-host.mjs via PtySession (server/pty/session.ts)."); + expect(loads.map((load) => load.file)).toContain("server/pty/pty-host.mjs"); + }); + + it("knows an import from a mention", () => { + expect(LOAD.test("import * as pty from \"@lydell/node-pty\";")).toBe(true); + expect(LOAD.test("const pty = await import('node-pty')")).toBe(true); + expect(LOAD.test("const pty = createRequire(import.meta.url)(\"@lydell/node-pty\");")).toBe(true); + expect(LOAD.test("Bun.resolveSync(\"@lydell/node-pty\", import.meta.dir);")).toBe(false); + }); +}); + +describe("web push", () => { + // web-push's sendNotification goes through node:https, which Bun does not carry the way it + // needs; push.ts builds each request with generateRequestDetails and sends it with fetch. + it("never calls sendNotification", () => { + fail(matches(files("{server,src,shared,scripts,telemetry}/**/*.{ts,tsx,mjs,js}").filter((file) => !file.endsWith(".test.ts")), /\bsendNotification\b/).map((match) => match.at), + "Web push requests are built with webpush.generateRequestDetails and sent with fetch (server/push.ts); never call sendNotification (AGENTS.md, Bridge invariants)."); + }); +}); + +/** The text of the `{ … }` that opens at `open`, strings respected. */ +function braces(code: string, open: number): string { + let depth = 0; + for (let i = open; i < code.length; i++) { + const ch = code[i]!; + if (ch === "\"" || ch === "'" || ch === "`") { + for (i += 1; i < code.length && code[i] !== ch; i += code[i] === "\\" ? 2 : 1); + continue; + } + if (ch === "{") depth += 1; + else if (ch === "}" && --depth === 0) return code.slice(open, i + 1); + } + return code.slice(open); +} + +describe("error bodies", () => { + // Every error body is `{ error: { code, message } }` (AGENTS.md, Changing a contract): the + // client reads `code` to decide and shows `message`. JSON answers go through jsonResponse or + // its wrappers in server/http.ts, so the content type and the envelope stay one shape. + it("builds JSON answers only through server/http.ts", () => { + fail(matches(serverCode().filter((file) => file !== "server/http.ts"), /\bResponse\.json\s*\(|new Response\s*\(\s*JSON\.stringify/).map((match) => match.at), + "Answer JSON with jsonResponse, errorResponse, badRequest or conflictResponse from server/http.ts, not Response.json or new Response(JSON.stringify(…))."); + }); + + /** An answer built from an object literal: jsonResponse or a local wrapper around it (`reply`, `fail`). */ + const ANSWER = /\b(?:jsonResponse|reply|fail)\s*\(\s*\{/; + /** The value of the object's own top-level `error` key (strings blanked), or null when it has none. */ + const errorValue = (object: string): string | null => { + for (let i = 1, depth = 0; i < object.length; i++) { + const ch = object[i]!; + if ("{[(".includes(ch)) depth += 1; + else if ("}])".includes(ch)) depth -= 1; + else if (depth === 0 && /^(? { + const blank = stripComments(code.slice(at), { strings: true }); + const object = braces(blank, blank.indexOf("{")); + const value = errorValue(object); + if (value === null) return false; + if (!value.startsWith("{")) return true; + const body = braces(value, 0); + return !/\bcode\s*[:,}]/.test(body) || !/\bmessage\s*[:,}]/.test(body); + }; + + it("gives every error body both a code and a message", () => { + const bodies = matches(serverCode(), ANSWER).filter((match) => errorValue(braces(stripComments(match.code.slice(match.index), { strings: true }), match.code.indexOf("{", match.index) - match.index)) !== null); + expect(bodies.length).toBeGreaterThan(30); + fail(bodies.filter((match) => malformed(match.code, match.index)).map((match) => match.at), + "An error body is { error: { code, message } } with a snake_case code: use badRequest(code, message) or conflictResponse(code, message) from server/http.ts, errorResponse(error) for a thrown error, or jsonResponse({ error: { code, message } }, status) for another status."); + }); + + it("reads an error body's shape", () => { + expect(malformed("return jsonResponse({ error: \"nope\" }, 400);", 7)).toBe(true); + expect(malformed("return reply({ error: { code: \"x\" } }, 400);", 7)).toBe(true); + expect(malformed("return jsonResponse({ error: { code, message: a ? b : \"c\" } }, 503);", 7)).toBe(false); + expect(malformed("return jsonResponse({ error: { message: \"invalid pairing code\" } }, 400);", 7)).toBe(true); + expect(malformed("return jsonResponse({ ok: false, error: \"nope\" }, 400);", 7)).toBe(true); + expect(malformed("return jsonResponse({ ok: true, data: { error: \"kept\" } });", 7)).toBe(false); + expect(ANSWER.test("this.patch({ phase: \"error\", error: \"failed\" });")).toBe(false); + }); +}); + +/** + * Unix-socket connections opened in `code` (comments removed): Bun.connect with `unix:` or with + * options it cannot read, and node:net's connect or createConnection (named, default or namespace + * import, or require) called with `path:` or with a single path-like argument. + */ +function unixConnects(file: string, code: string): { file: string; at: string; bun: boolean }[] { + const at = (index: number) => `${file}:${lineOf(code, index)}: ${code.split("\n")[lineOf(code, index) - 1]!.trim().slice(0, 160)}`; + /** The first argument of the call whose `(` ends at `open`. */ + const argument = (open: number): string => { + const rest = code.slice(open).trimStart(); + return rest.startsWith("{") ? braces(rest, 0) : /^[^,)]*/.exec(rest)![0].trim(); + }; + const found: { file: string; at: string; bun: boolean }[] = []; + for (const match of code.matchAll(/\bBun\.connect\s*(?:<[^>]*>)?\s*\(/g)) { + const arg = argument(match.index + match[0].length); + if (!arg.startsWith("{") || /\bunix\s*:/.test(arg)) found.push({ file, at: at(match.index), bun: true }); + } + const names: string[] = []; + for (const match of code.matchAll(/\bimport\s*\{([^}]*)\}\s*from\s*["'](?:node:)?net["']/g)) { + for (const item of match[1]!.matchAll(/\b(connect|createConnection)\b(?:\s+as\s+(\w+))?/g)) names.push(item[2] ?? item[1]!); + } + for (const match of code.matchAll(/\bimport\s+(?:\*\s*as\s+)?(\w+)\s*(?:,\s*\{[^}]*\}\s*)?from\s*["'](?:node:)?net["']|\b(?:const|let|var)\s+(\w+)\s*=\s*(?:require|await\s+import)\s*\(\s*["'](?:node:)?net["']\s*\)/g)) { + const space = match[1] ?? match[2]!; + names.push(`${space}.connect`, `${space}.createConnection`); + } + if (!names.length) return found; + for (const match of code.matchAll(new RegExp(`(? name.replace(".", "\\.")).join("|")})\\s*\\(`, "g"))) { + const arg = argument(match.index + match[0].length); + // TCP is `{ host, port }` or `(port, host)`; a path, an options object with `path:` or anything unreadable is a unix socket + if (arg.startsWith("{") ? /\bpath\s*:/.test(arg) : !/^\d|port/i.test(arg)) found.push({ file, at: at(match.index), bun: false }); + } + return found; +} + +describe("herdr socket", () => { + // herdr closes the socket after each answer, so every RPC opens its own connection in + // server/herdr/client.ts (no pooling), and only server/collector.ts holds a status + // subscription (AGENTS.md, Bridge invariants). + const UNIX_CONNECT_ALLOWED: Record = { + "server/herdr/client.ts": "the one herdr RPC client: one connection per call, plus subscribeEvents", + "server/herdr-marker.ts": "a liveness probe: connects and ends at once, sends no request", + }; + const NET_CONNECT_ALLOWED: Record = { + "server/ssh-askpass.ts": "the SSH askpass helper's own private socket, not herdr's", + }; + + it("opens herdr's socket only in server/herdr/client.ts", () => { + const uses = serverCode().flatMap((file) => unixConnects(file, stripComments(read(file)))); + expect(uses.map((use) => use.file)).toContain("server/herdr/client.ts"); + fail(uses.filter((use) => !(use.file in (use.bun ? UNIX_CONNECT_ALLOWED : NET_CONNECT_ALLOWED))).map((use) => use.at), + "Call herdr through the typed wrappers in server/herdr/client.ts: one connection per call, never a pooled or second client. A unix socket that is not herdr's gets an entry with its reason in UNIX_CONNECT_ALLOWED (Bun.connect) or NET_CONNECT_ALLOWED (node:net) in server/conventions.test.ts. A TCP connection (`{ host, port }`) is not flagged."); + }); + + it("tells a unix socket from a TCP one", () => { + const found = (code: string) => unixConnects("example.ts", code).length; + expect(found("Bun.connect({ unix: path, socket })")).toBe(1); + expect(found("Bun.connect(options)")).toBe(1); + expect(found("Bun.connect({ hostname: \"127.0.0.1\", port, socket })")).toBe(0); + expect(found("import { connect as dial } from \"node:net\";\ndial({ path: \"/tmp/herdr.sock\" });")).toBe(1); + expect(found("import net from \"node:net\";\nnet.createConnection({ path: socketPath });")).toBe(1); + expect(found("import * as net from \"node:net\";\nnet.connect(process.env.HERDR_SOCKET_PATH);")).toBe(1); + expect(found("import { connect as tcpConnect, createServer } from \"node:net\";\ntcpConnect({ host, port });\ntcpConnect(port, host);")).toBe(0); + }); + + // Server code calls herdr through the typed wrappers in server/herdr/client.ts (paneProcessInfo, + // agentGet, paneSendText, paneSendKeys, …), never through herdrRpc itself: the wrapper is where a + // method's params are typed (server/herdr/methods.ts), so `{ target }` to pane.process_info does + // not compile there. herdrRpc's untyped fallback still takes any params given an explicit result + // type, so this guard is what keeps server code off it. These files still call herdrRpc because + // open PRs change them; a follow-up PR moves them to the wrappers once those PRs land. + const HERDR_RPC_ALLOWED: Record = { + "server/conversation.ts": "open PRs #869 #912 #923 change it", + "server/gjc-runtime.ts": "open PR #912 changes it", + "server/hermes.ts": "open PR #910 changes it", + "server/omo.ts": "open PR #923 changes it", + "server/shell-agent.ts": "open PRs #885 #923 change it", + }; + // Files that do not call herdrRpc on main yet but whose open PR adds a `pane.process_info` call. + // They are exempt from the "no longer calls" check below. Remove an entry when its PR switches to + // paneProcessInfo(paneId) (the better outcome), merges, or closes. + const HERDR_RPC_PENDING: Record = { + "server/gjc-status.ts": "open PR #928 adds a call", + "server/index.ts": "open PR #859 adds a call", + }; + + it("calls herdrRpc only inside server/herdr/", () => { + const uses = matches(serverCode().filter((file) => !file.startsWith("server/herdr/")), /\bherdrRpc\b/); + fail(uses.filter((use) => !(use.file in HERDR_RPC_ALLOWED) && !(use.file in HERDR_RPC_PENDING)).map((use) => use.at), + "Do not call herdrRpc outside server/herdr/: add a typed wrapper in server/herdr/client.ts (its method in HerdrMethods, server/herdr/methods.ts) and call that; for pane status use the server/collector.ts listeners. Files waiting on open PRs are listed in HERDR_RPC_ALLOWED in server/conventions.test.ts; do not add new ones."); + const users = new Set(uses.map((use) => use.file)); + fail(Object.keys(HERDR_RPC_ALLOWED).filter((file) => !users.has(file)), + "These files no longer call herdrRpc: remove them from HERDR_RPC_ALLOWED in server/conventions.test.ts."); + }); + + it("subscribes to status only in server/collector.ts", () => { + const subscribers = matches(serverCode(), /\bsubscribeEvents\b/).filter((match) => match.file !== "server/herdr/client.ts" && match.file !== "server/collector.ts"); + const raw = matches(files("{server,src,shared,scripts}/**/*.{ts,tsx,mjs,js}").filter((file) => !file.endsWith(".test.ts")), /["']events\.subscribe["']/).filter((match) => match.file !== "server/herdr/client.ts"); + fail([...subscribers, ...raw].map((match) => match.at), + "server/collector.ts is the only status subscription (startStatusCollector), and `events.subscribe` is sent only by subscribeEvents in server/herdr/client.ts: take pane status from the collector's callbacks (onStatus, onPaneEnded, onBaseline) in server/index.ts instead of subscribing again."); + }); +}); diff --git a/server/conversation.test.ts b/server/conversation.test.ts index b3b0f0891..6f4a0e1be 100644 --- a/server/conversation.test.ts +++ b/server/conversation.test.ts @@ -22,7 +22,7 @@ let mockedProcesses: { argv?: unknown }[] = []; describe("Devin pane identity", () => { const restores: Array<() => void> = []; beforeEach(() => { - const rpc = spyOn(herdr, "herdrRpc").mockImplementation(async (method) => { + const rpc = spyOn(herdr, "herdrRpc").mockImplementation(async (method: string) => { if (method !== "pane.process_info") throw new Error(`unexpected conversation fixture RPC: ${method}`); return { process_info: { foreground_processes: mockedProcesses } } as never; }); @@ -207,7 +207,8 @@ describe("parseClaudeTranscript", () => { { role: "user", ts: "2026-09-19T08:00:00.000Z", parts: [{ kind: "text", text: "리팩터링 시작해줘" }] }, { role: "assistant", ts: "2026-09-19T08:00:02.000Z", end_ts: "2026-09-19T08:00:05.000Z", parts: [ { kind: "text", text: "먼저 상태를 확인하겠습니다." }, - { kind: "tool", name: "Bash", summary: "git status --short", input: expect.stringContaining("git status"), output: "M src/app.ts" }, + // the row says what the agent said the command is for; the command stays in the input + { kind: "tool", name: "Bash", summary: "check tree", input: expect.stringContaining("git status"), output: "M src/app.ts" }, { kind: "thinking", text: "internal reasoning stays private" }, { kind: "text", text: "변경된 파일이 하나입니다." }, ] }, @@ -429,6 +430,22 @@ describe("parseClaudeTranscript", () => { expect(assistant?.parts.map((part) => part.kind)).toEqual(["text", "tool", "thinking", "text"]); }); + it("reads thinking that Claude Code displays as updates as narration in the work", () => { + // the shape a recent Claude Code writes: one entry per block, the visible update as thinking + const entry = (thinkingDisplay: string | undefined, block: object) => JSON.stringify({ type: "assistant", timestamp: "2026-10-14T07:00:00.000Z", ...(thinkingDisplay ? { thinkingDisplay } : {}), message: { role: "assistant", stop_reason: null, content: [block] } }); + const turns = parseClaudeTranscript([ + JSON.stringify({ type: "user", message: { role: "user", content: "measure the padding" } }), + entry("updates", { type: "thinking", thinking: "", signature: "s" }), + entry("updates", { type: "thinking", thinking: "The padding stays; measuring it next.", signature: "s" }), + entry("updates", { type: "tool_use", id: "t", name: "Bash", input: { command: "true" } }), + entry(undefined, { type: "thinking", thinking: "private reasoning", signature: "s" }), + ].join("\n")); + expect(turns[1]?.parts.filter((part) => part.kind !== "tool")).toEqual([ + { kind: "text", text: "The padding stays; measuring it next.", phase: "commentary" }, + { kind: "thinking", text: "private reasoning" }, + ]); + }); + it("survives a torn tail line while Claude is mid-append", () => { expect(parseClaudeTranscript(`${lines}\n{"type":"ass`).length).toBe(2); }); diff --git a/server/conversation.ts b/server/conversation.ts index d3fa4477a..c0f8ec5e2 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -9,15 +9,15 @@ * - omp: herdr's agent.get hands us the session jsonl path outright under * ~/.omp/agent/sessions//, or ~/.omp/profiles//agent/sessions// * for `omp --profile ` — same shape of truth, one less hop. When herdr names none, the - * pane's omp process does, as gjc's does (omp.ts). + * pane's omp process does (omp.ts). * - omo: herdr knows nothing about its store and its label for the pane flips * between `pi` and `claude` as omo spawns model CLIs, so the pane's process * tree routes it and process/session evidence selects a unique transcript * under /sessions// (~/.omo/agent unless the process * moved it with OMO_CODING_AGENT_DIR). It writes omp's session shape, so * parseOmpTranscript (transcript-records.ts) reads it. - * - gjc: an open session file or fresh native terminal breadcrumb belonging to - * its process (gjc-runtime.ts). It writes omp's session shape too. + * - gjc: the public SDK broker binds its live pane process to the exact saved + * session transcript (gjc-sdk.ts, gjc-runtime.ts). It writes omp's session shape too. * - pi: herdr's agent.get names the session jsonl outright under * ~/.pi/agent/sessions// (pi.ts). Its records are the session shape * parseOmpTranscript reads, but its file is an entry tree, not a log: /tree @@ -38,22 +38,23 @@ import { closeSync, openSync, readFileSync, readSync, realpathSync, statSync } f import nodePath, { type PlatformPath } from "node:path"; import type { ConversationMetadata, ConversationPart, ConversationTurn, HerdrPane, SessionSnapshot } from "../shared/protocol.ts"; -import { herdrRpc, sessionSnapshot } from "./herdr/client.ts"; +import { herdrRpc, paneRead, sessionSnapshot } from "./herdr/client.ts"; import { codexHistorySegments, createCodexTranscriptParser, codexOutputText, codexTranscriptPath, defaultCodexHome, forgetAllCodexState, forgetCodexStateFor, paneCodexHome, parseCodexTranscript, readRange } from "./codex.ts"; import { CODEX_IMAGE_REF, codexTranscriptImage } from "./codex-images.ts"; import { AmbiguousClaudeStore, claudeProcessSession, claudeTranscriptFile, defaultClaudeConfigDir, forgetClaudeSessionFile, forgetClaudeSessions, isClaudeProcess, processClaudeConfigDir } from "./claude-store.ts"; -import { forgetGjcPane, forgetGjcState, gjcPidUnderShell, gjcTranscriptForPane, isGjcProcess, storeRelative } from "./gjc-runtime.ts"; -import { isOmoProcess, omoSessionForPane } from "./omo.ts"; +import { forgetGjcState, gjcPidUnderShell, gjcTranscriptForPane, isGjcProcess, storeRelative } from "./gjc-runtime.ts"; +import { omoProcessIdentity, omoSessionForPane } from "./omo.ts"; import { processStartedAt } from "./process-start.ts"; import { ompHeldTranscript } from "./omp.ts"; import { forgetOpencodeRead, forgetOpencodeState, OPENCODE_IMAGE_REF, opencodeConversation, opencodeDatabasePath, opencodeImage, opencodeReadKey, opencodeSessionId, opencodeToolOutput } from "./opencode.ts"; import { piTranscriptInStore, piTranscriptPath, unwrittenSession } from "./pi.ts"; -import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchSegments } from "./pi-tree.ts"; import { defaultDevinDbPath, DevinHistoryUnavailable, devinConversation, forgetDevinState } from "./devin.ts"; import { forgetGrokState, grokProjection, grokToolOutput, parseGrokTranscript, GrokUnavailable } from "./grok.ts"; import { resolveGrokStore } from "./grok-store.ts"; import { AgyUnavailable, agyCatalog, agyToolOutput, forgetAgyState, parseAgyTranscript, withAgyHistory, type AgyProjection } from "./agy.ts"; import { resolveAgyStore, sameAgyStore, type AgyStore } from "./agy-store.ts"; +import { withinRetentionBudget } from "./retention-budget.ts"; +import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchRecords, piBranchSegments } from "./pi-tree.ts"; import { trimOutput } from "./tool-output.ts"; import { parseConversationMetadata } from "./conversation-metadata.ts"; import { forgetHermesTranscriptState, hermesConversationPage, hermesToolOutput, hermesTranscriptForPane } from "./hermes.ts"; @@ -61,6 +62,8 @@ import { forgetHermesTranscriptState, hermesConversationPage, hermesToolOutput, import { invokedSkill } from "./skill-activity.ts"; import { agentTaskResult, claudePeerId, claudePeerNotice, isContextClear, MAX_TURNS, parseOmpTranscript, piImageBlock, piMessage, piResults, toolSummary } from "./transcript-records.ts"; import { forgetSubagents, lineNotifications, subagentDetails, taskNotification, type SubagentDetail } from "./claude-subagents.ts"; +import { launchedAgents } from "./launched-agents.ts"; +import { readsStartScreen, startScreenMetadata } from "./start-screen.ts"; export { isOmoProcess } from "./omo.ts"; @@ -71,6 +74,7 @@ export { isOmoProcess } from "./omo.ts"; * tool-heavy session; the chat asks for the pages before it as the reader scrolls up. */ export const TRANSCRIPT_WINDOW_BYTES = 16 * 1024 * 1024; +const PI_PAGE_CACHE_BYTES = 4 * 1024 * 1024; /** A page never holds more prompts than this (each opens a user + assistant pair). */ const MAX_PAGE_PROMPTS = MAX_TURNS / 2; @@ -130,11 +134,6 @@ function localCommandOutput(content: unknown): string { /** Lines of one notice Claude Code writes as separate entries, a moment apart. */ const NOTICE_JOIN_MS = 1000; -/** A notice Claude Code writes as plain text: no terminal codes, and not past a notice's length. */ -function noticeText(raw: string): string { - return capNotice(stripTerminalControls(raw).trim()); -} - /** A notice is not a log: past the cap the rest stays in the terminal, also for lines joined into one. */ function capNotice(text: string): string { return text.length > LOCAL_COMMAND_MAX_CHARS ? `${text.slice(0, LOCAL_COMMAND_MAX_CHARS)}\u2026` : text; @@ -228,6 +227,8 @@ interface TranscriptEntry { parentUuid?: string | null; isMeta?: boolean; isCompactSummary?: boolean; + /** "updates": Claude Code shows the entry's thinking text as a progress message, not as reasoning */ + thinkingDisplay?: unknown; message?: { role?: string; content?: unknown; stop_reason?: unknown }; attachment?: { type?: unknown; prompt?: unknown; commandMode?: unknown; origin?: { kind?: unknown } }; } @@ -256,6 +257,8 @@ export function parseClaudeTranscript(text: string, maxTurns = MAX_TURNS, option const pending = new Map>(); /** the agent's last entry called a tool: its turn is not over, whatever is written meanwhile */ let atWork = false; + /** injected feedback can resume the model without a human prompt after a completed reply */ + let completed = false; /** where a notice goes: before the turn at work it was written in, else at the end */ const noticeAt = (): number => atWork && turns.at(-1)?.role === "assistant" ? turns.length - 1 : turns.length; /** the notifications already drawn: a completion is recorded in up to three places */ @@ -274,7 +277,7 @@ export function parseClaudeTranscript(text: string, maxTurns = MAX_TURNS, option const assistantTurn = (ts?: string): ConversationTurn => { const last = turns[turns.length - 1]; - if (last !== undefined && last.role === "assistant") return last; + if (last !== undefined && last.role === "assistant" && !completed) return last; const turn: ConversationTurn = { role: "assistant", ts: ts ?? null, parts: [] }; turns.push(turn); return turn; @@ -357,7 +360,7 @@ export function parseClaudeTranscript(text: string, maxTurns = MAX_TURNS, option // goes before the turn it was written in, so the turn stays whole and stays the last one, // which is what the chat reads as the turn still running. if (entry.type === "system" && entry.subtype === "informational") { - const text = typeof entry.content === "string" ? noticeText(entry.content) : ""; + const text = typeof entry.content === "string" ? capNotice(stripTerminalControls(entry.content).trim()) : ""; if (text.length === 0) continue; const ts = entry.timestamp ?? null; const at = noticeAt(); @@ -410,6 +413,7 @@ export function parseClaudeTranscript(text: string, maxTurns = MAX_TURNS, option const turn = assistantTurn(entry.timestamp); // an entry that names no stop reason is still at work when it calls a tool const stop = entry.message?.stop_reason; + completed = stop === "end_turn"; atWork = stop === "tool_use" || (stop == null && content.some((block: unknown) => typeof block === "object" && block !== null && (block as { type?: unknown }).type === "tool_use")); if (entry.timestamp) turn.end_ts = entry.timestamp; for (const block of content) { @@ -419,7 +423,10 @@ export function parseClaudeTranscript(text: string, maxTurns = MAX_TURNS, option turn.parts.push({ kind: "text", text: b.text }); } else if (b.type === "thinking") { const thinking = typeof b.thinking === "string" ? b.thinking : typeof b.text === "string" ? b.text : ""; - if (thinking.length > 0) turn.parts.push({ kind: "thinking", text: thinking }); + // the terminal prints an "updates" entry's thinking as narration between the tools, so it + // is read as that: commentary keeps it in the work block, never taken for the answer + if (thinking.trim().length > 0 && entry.thinkingDisplay === "updates") turn.parts.push({ kind: "text", text: thinking, phase: "commentary" }); + else if (thinking.length > 0) turn.parts.push({ kind: "thinking", text: thinking }); } else if (b.type === "tool_use" && typeof b.name === "string") { const input = (typeof b.input === "object" && b.input !== null ? b.input : {}) as Record; const part: Extract = { @@ -446,7 +453,7 @@ export function parseClaudeTranscript(text: string, maxTurns = MAX_TURNS, option const writtenSessions = new Set(); /** Re-parse on file changes, including replacement and same-size rewrites. */ -const cache = new Map(); +const cache = new Map(); export class ConversationUnavailable extends Error { constructor(reason: string) { @@ -783,6 +790,7 @@ interface LiveScan { const liveScans = new Map(); interface SettledTurns { + source: RecognizedConversation["source"]; id: string; /** the page start these turns begin at, and the turn start they end at */ start: number; @@ -847,6 +855,15 @@ function rememberPaneDevin(paneId: string, sessionId: string, cwd: string, dbPat remember(paneReads, paneId, reads, PANE_READS_MAX); } +/** Pi retains at most four small parsed pages per cache; oversized pages remain readable uncached. */ +function rememberPage(map: Map, key: string, value: T, limit: number): void { + map.delete(key); + if (value.source === "pi-transcript" && !withinRetentionBudget(value, PI_PAGE_CACHE_BYTES)) return; + remember(map, key, value, limit); + const piKeys = [...map].filter(([, item]) => item.source === "pi-transcript").map(([key]) => key); + while (piKeys.length > 4) map.delete(piKeys.shift()!); +} + /** The newest page's start and every turn start in it (pageBefore without widening), or null when it starts mid-turn. */ function newestPage(path: string, stream: TranscriptStream, source: StreamSource): { start: number; starts: number[] } | null { const from = Math.max(stream.floor, stream.length - TRANSCRIPT_WINDOW_BYTES); @@ -982,14 +999,15 @@ function liveTurns(path: string, stream: TranscriptStream, source: RecognizedCon const to = Math.min(start, kept.observedEnd); if (to > from) parseTurns(source, path, readStream(stream, from, to).toString("utf8"), inherited); } - settled = { id: stream.id, start, end: start, turns: [], metadata: parseConversationMetadata(`${head}\n`, source), tail: bytesBefore(stream, start), taskTitles: new Map(inherited), noticed: new Set(), inherited, observedEnd: stream.length }; + settled = { source, id: stream.id, start, end: start, turns: [], metadata: parseConversationMetadata(`${head} +`, source), tail: bytesBefore(stream, start), taskTitles: new Map(inherited), noticed: new Set(), inherited, observedEnd: stream.length }; } if (settled.end < last) { const text = readStream(stream, settled.end, last).toString("utf8"); settled = { ...settled, end: last, turns: [...settled.turns, ...parseTurns(source, path, text, settled.taskTitles, settled.noticed)], metadata: parseConversationMetadata(text, source, settled.metadata), tail: bytesBefore(stream, last) }; } settled.observedEnd = stream.length; - remember(settledTurns, key, settled, 8); + rememberPage(settledTurns, key, settled, 8); if (source === "codex-transcript") { const live = codexLiveTurn(path, stream, last, settled.metadata); return { turns: [...settled.turns, ...live.turns], metadata: live.metadata }; @@ -1000,8 +1018,16 @@ function liveTurns(path: string, stream: TranscriptStream, source: RecognizedCon return { turns: [...settled.turns, ...parseTurns(source, path, text, new Map(settled.taskTitles), new Set(settled.noticed))], metadata: parseConversationMetadata(text, source, settled.metadata) }; } +/** Retention diagnostics only: counts/budget adherence, never transcript contents. */ +export function piPageCacheUsage(): { pages: number; settled: number; withinBudget: boolean } { + const pages = [...cache.values()].filter(item => item.source === "pi-transcript"); + const settled = [...settledTurns.values()].filter(item => item.source === "pi-transcript"); + return { pages: pages.length, settled: settled.length, withinBudget: [...pages, ...settled].every(item => withinRetentionBudget(item, PI_PAGE_CACHE_BYTES)) }; +} + /** Forget every scan and parse kept between polls (tests compare against a cold read). */ export function forgetTranscriptState(): void { + launchedAgents.clear(); cache.clear(); writtenSessions.clear(); forgetClaudeSessions(); @@ -1032,7 +1058,7 @@ export function forgetTranscriptState(): void { export function forgetPaneTranscriptState(paneId: string): void { const reads = paneReads.get(paneId); paneReads.delete(paneId); - forgetGjcPane(paneId); + launchedAgents.forget(paneId); if (reads === undefined) return; // two panes can read one transcript (a resumed session, a shared store): what another pane // still reads stays @@ -1087,12 +1113,18 @@ export async function gjcTranscriptPath(paneId: string, cwd: string, home?: stri export async function paneRunsOmo(paneId: string): Promise { // pane.process_info wants `pane_id`; given `target` herdr answers for the // FOCUSED pane instead of erroring (live-verified 2026-09-21). - const info = await herdrRpc<{ process_info?: { foreground_processes?: { argv?: unknown }[] } }>( + const info = await herdrRpc<{ process_info?: { foreground_processes?: { pid?: unknown; argv?: unknown; cwd?: unknown }[] } }>( "pane.process_info", { pane_id: paneId }, ).catch(() => null); + const home = process.env["HOME"] ?? ""; return (info?.process_info?.foreground_processes ?? []).some((process) => - isOmoProcess(Array.isArray(process.argv) ? process.argv.map(String) : []), + // a pid herdr does not report keeps the argv-only proof and fails the custom one closed + omoProcessIdentity({ + pid: typeof process.pid === "number" ? process.pid : -1, + argv: Array.isArray(process.argv) ? process.argv.map(String) : undefined, + cwd: typeof process.cwd === "string" && process.cwd ? process.cwd : undefined, + }, "", home) !== null, ); } @@ -1103,7 +1135,7 @@ export async function paneRunsOmo(paneId: string): Promise { * (paneRunsOmo), including panes herdr has not recognized as an agent. */ export async function labelOmoPanes(snapshot: SessionSnapshot): Promise { - const candidates = snapshot.panes.filter((pane) => !pane.agent || pane.agent === "pi" || pane.agent === "claude"); + const candidates = snapshot.panes; const omo = new Set(); await Promise.all(candidates.map(async (pane) => { if (await paneRunsOmo(pane.pane_id)) omo.add(pane.pane_id); })); if (omo.size === 0) return snapshot; @@ -1245,7 +1277,7 @@ async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: strin ); if (running || await gjcPidUnderShell(info?.process_info?.shell_pid)) agent = "gjc"; } - if ((agent === "omo" || agent === "pi" || agent === "claude") && await paneRunsOmo(paneId)) { + if (await paneRunsOmo(paneId)) { return { source: "omo-transcript", path: await omoTranscriptPath(paneId, cwd, panes) }; } // omp and GJC can change their own cwd without changing the pane's shell directory; their @@ -1362,7 +1394,7 @@ export async function paneTranscript(paneId: string, codexHome?: string, snapsho if (pane === undefined) throw new ConversationUnavailable("pane_not_found"); if (typeof pane.cwd !== "string" || pane.cwd.length === 0) throw new ConversationUnavailable("no_recognized_transcript"); - if (pane.agent === "devin") { + if (pane.agent === "devin" && !(await paneRunsOmo(paneId))) { const cwd = pane.foreground_cwd || pane.cwd; const storePath = devinDbPath ?? defaultDevinDbPath(); const info = await herdrRpc<{ process_info?: { foreground_processes?: { argv?: unknown }[] } }>( @@ -1401,7 +1433,15 @@ export async function paneConversation(paneId: string, codexHome?: string, page: const snapshot = await sessionSnapshot(); let resolved: NativeTranscript; - const onNotStarted = (error: ConversationNotStarted): RecognizedConversation => { + // no transcript states the model yet: the agent's own screen does (start-screen.ts). A screen + // that cannot be read leaves it unknown, as it was + const notBegun = async (source: RecognizedConversation["source"], id: string): Promise => { + const screen = readsStartScreen(source) ? await paneRead({ paneId, source: "visible", format: "text", timeoutMs: 2_000 }).then((read) => read.text, () => "") : ""; + const metadata = startScreenMetadata(source, screen); + return { source, turns: [], metadata, cursor: null, history_id: id, version: answerVersion(id, `${metadata.model}\0${metadata.reasoning_effort}`) }; + }; + + const onNotStarted = (error: ConversationNotStarted): Promise => { // a file read before and gone since is no conversation not begun: the terminal stands in for it if (writtenSessions.has(`${error.source}\0${error.identity}`)) throw new ConversationUnavailable("transcript_missing"); // nothing comes before a conversation not begun: a cursor into it is another one's @@ -1411,15 +1451,22 @@ export async function paneConversation(paneId: string, codexHome?: string, page: if (status === "working" || status === "blocked") throw error; // the chat says there is nothing yet; the session's first prompt writes the file and // the next refresh's history_id differs, so the chat takes it whole - const id = `unwritten:${error.sessionId}`; - return { source: error.source, turns: [], metadata: { model: null, reasoning_effort: null }, cursor: null, history_id: id, version: answerVersion(id, "") }; + return notBegun(error.source, `unwritten:${error.sessionId}`); }; try { resolved = await paneTranscript(paneId, codexHome, snapshot, devinDbPath, opencodeDb, hermesHome, grokHome); + launchedAgents.forget(paneId); } catch (error) { - if (error instanceof ConversationNotStarted) return onNotStarted(error); - throw error; + if (error instanceof ConversationNotStarted) { launchedAgents.forget(paneId); return await onNotStarted(error); } + if (!(error instanceof ConversationUnavailable)) throw error; + const pane = snapshot.panes.find((candidate) => candidate.pane_id === paneId); + if (pane === undefined) { launchedAgents.forget(paneId); throw error; } + // an agent this server started writes no session before its first message (launched-agents.ts) + const launch = launchedAgents.notBegun(paneId, pane.agent_status); + if (launch === null) throw error; + if (page.before !== undefined || page.since !== undefined || page.from !== undefined) throw new HistoryChanged(); + return await notBegun(launch.source, launch.historyId); } if (resolved.source === "devin-transcript") { try { @@ -1449,7 +1496,7 @@ export async function paneConversation(paneId: string, codexHome?: string, page: rememberPaneSession(paneId, `${resolved.source}\0${resolved.sessionId}`); return answer; } catch (error) { - if (error instanceof ConversationNotStarted) return onNotStarted(error); + if (error instanceof ConversationNotStarted) return await onNotStarted(error); throw error; } } @@ -1564,8 +1611,7 @@ function transcriptPageRead(source: StreamSource, path: string, page: Conversati // the abandoned turns live outside the branch's byte ranges, so the page cannot see them: a // straight line answers { count: 0 }, which is most sessions and costs the client nothing const abandoned = source === "pi-transcript" ? piAbandonedTurns(path, stat.size) ?? undefined : undefined; - cache.set(key, { signature, turns, metadata, cursor, abandoned }); - if (cache.size > 32) cache.delete(cache.keys().next().value!); + rememberPage(cache, key, { source, signature, turns, metadata, cursor, abandoned }, 32); return { source, turns, metadata, cursor, abandoned, history_id: stream.id, version }; } @@ -1673,16 +1719,19 @@ function piToolOutput(path: string, ref: string): string | null { let branch: ReturnType; try { size = statSync(path).size; branch = piBranchSegments(path, size); } catch { return null; } if (branch === null) return null; - const fd = openSync(path, "r"); - try { - for (const segment of branch) { - const buffer = Buffer.alloc(segment.end - segment.start); - if (readSync(fd, buffer, 0, buffer.length, segment.start) !== buffer.length) continue; - const output = outputInText("pi-transcript", buffer.toString("utf8"), ref); - if (output !== null) return output; + // only a record that can be a clear or can hold the ref is parsed: an unrelated record (a picture + // the agent read, a long tool result) is skipped on a substring check, as the segment reader did + const needle = JSON.stringify(ref); + let output: string | null = null; + for (const line of piBranchRecords(path, size)) { + if (line.includes("context_clear")) { + let entry: unknown; + try { entry = JSON.parse(line); } catch { continue; } + if (isContextClear(entry, "pi-transcript")) { output = null; continue; } } - } finally { closeSync(fd); } - return null; + if (output === null && line.includes(needle)) output = outputInText("pi-transcript", line, ref); + } + return output; } /** @@ -1698,22 +1747,15 @@ export function piTranscriptImage(path: string, ref: string): { mediaType: strin let branch: ReturnType; try { size = statSync(path).size; branch = piBranchSegments(path, size); } catch { return null; } if (branch === null) return null; - const fd = openSync(path, "r"); - try { - for (const segment of branch) { - const buffer = Buffer.alloc(segment.end - segment.start); - if (readSync(fd, buffer, 0, buffer.length, segment.start) !== buffer.length) continue; - for (const line of buffer.toString("utf8").split("\n")) { - if (!line.includes(callId)) continue; - let entry: unknown; - try { entry = JSON.parse(line); } catch { continue; } - const message = piMessage(entry); - if (message === null) continue; - const image = piImageBlock(message, callId, Number(nth)); - if (image !== null) return { mediaType: image.media_type, bytes: new Uint8Array(Buffer.from(image.data, "base64")) }; - } - } - } finally { closeSync(fd); } + for (const line of piBranchRecords(path, size)) { + if (!line.includes(callId)) continue; + let entry: unknown; + try { entry = JSON.parse(line); } catch { continue; } + const message = piMessage(entry); + if (message === null) continue; + const image = piImageBlock(message, callId, Number(nth)); + if (image !== null) return { mediaType: image.media_type, bytes: new Uint8Array(Buffer.from(image.data, "base64")) }; + } return null; } diff --git a/server/device-access.contract.test.ts b/server/device-access.contract.test.ts index 2f8f81d04..878188407 100644 --- a/server/device-access.contract.test.ts +++ b/server/device-access.contract.test.ts @@ -47,7 +47,9 @@ it("revocation closes an active device's terminal and roster stream without disc const { code } = await (await fetch(`${base}/api/devices/pair/start`, { method: "POST", headers: admin })).json() as { code: string }; const response = await fetch(`${base}/api/devices/pair`, { method: "POST", headers: guard, body: JSON.stringify({ code, label }) }); expect(response.status).toBe(204); - return response.headers.get("set-cookie")!.split(";")[0]!; + const cookie = response.headers.get("set-cookie")!.split(";")[0]!; + expect((await fetch(`${base}/api/session`, { headers: { cookie } })).status).toBe(200); + return cookie; }; const cookieA = await pair("A"); const cookieB = await pair("B"); @@ -98,10 +100,67 @@ it("a corrupt device registry refuses strangers while keeping local recovery and } finally { server.stop(); rmSync(root, { recursive: true, force: true }); } }); +it("keeps a forwarded first-pair gate open until a returned cookie is durably confirmed", async () => { + const root = mkdtempSync(join(tmpdir(), "herdr-first-pair-proxy-")); + const options = { port: 0, stateDir: root, token: "", tailscaleOwner: null, machines: false } as const; + let server = createServer(options); + let base = `http://127.0.0.1:${server.port}`; + const forwarded = { "x-forwarded-for": "198.51.100.24" }; + const start = async () => { + const response = await fetch(`${base}/api/devices/pair/start`, { method: "POST", headers: { origin: base, "x-herdr-machine": "1", ...forwarded } }); + expect(response.status).toBe(200); + return (await response.json() as { code: string }).code; + }; + const issue = async (code: string) => { + const response = await fetch(`${base}/api/devices/pair`, { method: "POST", headers: { origin: base, "x-herdr-machine": "1", "content-type": "application/json", ...forwarded }, body: JSON.stringify({ code }) }); + expect(response.status).toBe(204); + return response.headers.get("set-cookie")!.split(";")[0]!; + }; + const stranger = () => fetch(`${base}/api/devices`, { headers: forwarded }); + try { + const droppedCookie = await issue(await start()); + expect((await stranger()).status).toBe(200); + expect((await (await fetch(`${base}/api/devices`, { headers: forwarded })).json()).devices).toEqual([]); + expect(() => readFileSync(join(root, "devices.json"), "utf8")).toThrow(); + + // A lost cookie can be retried through the proxy; issuing another cookie still does + // not close the gate until one of them returns in a later request. + const retryCookie = await issue(await start()); + expect((await stranger()).status).toBe(200); + expect(retryCookie).not.toBe(droppedCookie); + server.stop(); + + // No first-pair candidate or gate survives a restart before confirmation. + server = createServer(options); + base = `http://127.0.0.1:${server.port}`; + expect((await stranger()).status).toBe(200); + const confirmedCookie = await issue(await start()); + expect((await stranger()).status).toBe(200); + const blocked = join(root, `devices.json.${process.pid}.tmp`); + mkdirSync(blocked); + const failed = await fetch(`${base}/api/devices`, { headers: { ...forwarded, cookie: confirmedCookie } }); + expect(failed.status).toBe(500); + expect(failed.headers.get("content-type")).toContain("application/json"); + expect(await failed.json()).toMatchObject({ error: { code: "internal_error" } }); + expect((await stranger()).status).toBe(200); + expect(() => readFileSync(join(root, "devices.json"), "utf8")).toThrow(); + rmSync(blocked, { recursive: true }); + expect((await fetch(`${base}/api/devices`, { headers: { ...forwarded, cookie: confirmedCookie } })).status).toBe(200); + expect((await stranger()).status).toBe(401); + const registry = JSON.parse(readFileSync(join(root, "devices.json"), "utf8")) as { gate_closed_at: string | null; devices: unknown[] }; + expect(registry.gate_closed_at).not.toBeNull(); + expect(registry.devices).toHaveLength(1); + } finally { + server.stop(); + rmSync(root, { recursive: true, force: true }); + } +}); + it("watch credentials cannot mutate HTTP state, read credential files, or elevate local and relayed terminals", async () => { const root = mkdtempSync(join(tmpdir(), "herdr-watch-access-")); const store = new DeviceStore(root); const watch = store.pair(store.startPairing().code, "Watch", "watch")!; + store.match(watch.token); const server = createServer({ port: 0, stateDir: root, token: "test-watch", tailscaleOwner: null, machines: false }); const base = `http://127.0.0.1:${server.port}`; const cookie = `herdr_web_device=${watch.token}`; @@ -130,6 +189,15 @@ it("watch credentials cannot mutate HTTP state, read credential files, or elevat expect(response.status).toBe(403); if (method === "GET") expect(await response.json()).toMatchObject({ error: { code: "read_only" } }); } + // a repository's changes are file contents: refused like a file read, on this PC and through a PC's route + for (const path of ["workspace/diff/files", "workspace/diff/file", "machines/pc1/workspace/diff/files", "machines/pc1/workspace/diff/file", "machines/local/workspace/diff/file"]) { + const response = await fetch(`${base}/api/${path}?directory=${encodeURIComponent(root)}&path=devices.json`, { headers }); + expect([path, response.status]).toEqual([path, 403]); + expect(await response.json()).toMatchObject({ error: { code: "read_only" } }); + } + for (const path of ["api//workspace/diff/file", "api/workspace//diff/file", "api/machines/pc1//workspace/diff/file"]) { + expect((await fetch(`${base}/${path}?directory=${encodeURIComponent(root)}&path=devices.json`, { headers })).status).toBe(404); + } // an empty segment must not turn a file read into "some other route" (the PC proxy drops it) for (const path of ["api//fs/file", "api/machines/pc1//fs/file", "api/machines/pc1/fs//file"]) { expect((await fetch(`${base}/${path}?path=${encodeURIComponent(join(root, "devices.json"))}`, { headers })).status).toBe(404); @@ -181,6 +249,7 @@ it("returns an error when revocation cannot be persisted, then allows a successf const root = mkdtempSync(join(tmpdir(), "herdr-device-write-")); const store = new DeviceStore(root); const paired = store.pair(store.startPairing().code, "Phone", "drive")!; + store.match(paired.token); const server = createServer({ port: 0, stateDir: root, token: "test-write", tailscaleOwner: null, machines: false }); const base = `http://127.0.0.1:${server.port}`; const blocked = join(root, `devices.json.${process.pid}.tmp`); diff --git a/server/devices.test.ts b/server/devices.test.ts index d490fdfc1..20903ba22 100644 --- a/server/devices.test.ts +++ b/server/devices.test.ts @@ -27,12 +27,26 @@ describe("DeviceStore", () => { const fresh = new DeviceStore(mkdtempSync(join(dir, "fresh-"))); expect(fresh.error).toBeNull(); expect(fresh.gated).toBe(false); + + const legacy = mkdtempSync(join(dir, "legacy-gate-")); + writeFileSync(join(legacy, "devices.json"), JSON.stringify({ + gate_closed_at: null, + devices: [{ id: "0123456789abcdef", label: "Legacy", role: "drive", token_hash: "hash", created_at: new Date().toISOString(), last_seen_at: null }], + })); + const legacyStore = new DeviceStore(legacy); + expect(legacyStore.gated).toBe(true); + expect(legacyStore.list(null)[0]?.last_seen_at).toBeNull(); + expect(legacyStore.revoke("0123456789abcdef")).toBe(true); + expect(legacyStore.gated).toBe(true); + expect(new DeviceStore(legacy).gated).toBe(true); }); it("closes only the revoked device's listeners, including late registrations", () => { const store = new DeviceStore(mkdtempSync(join(dir, "listeners-"))); const a = store.pair(store.startPairing().code, "A", "drive")!; + expect(store.match(a.token)).not.toBeNull(); const b = store.pair(store.startPairing().code, "B", "drive")!; + expect(store.match(b.token)).not.toBeNull(); const closed: string[] = []; store.onRevoke(a.device.id, () => closed.push("a")); store.onRevoke(b.device.id, () => closed.push("b")); @@ -53,11 +67,17 @@ describe("DeviceStore", () => { const paired = store.pair(code.slice(0, 3) + " " + code.slice(3), "My phone", "drive"); expect(paired).not.toBeNull(); expect(store.pair(code, "again", "drive")).toBeNull(); // spent + expect(store.gated).toBe(false); + expect(store.list(null)).toEqual([]); + expect(() => statSync(join(dir, "devices.json"))).toThrow(); + expect(new DeviceStore(dir).gated).toBe(false); // an unreturned cookie is not durable across restart + expect(new DeviceStore(dir).match(paired!.token)).toBeNull(); + expect(store.match(paired!.token)).toEqual({ id: paired!.device.id, label: "My phone", role: "drive" }); expect(store.gated).toBe(true); const file = readFileSync(join(dir, "devices.json"), "utf8"); expect(file).not.toContain(paired!.token); expect((statSync(join(dir, "devices.json")).mode & 0o777)).toBe(0o600); - expect(store.match(paired!.token)).toEqual({ id: paired!.device.id, label: "My phone", role: "drive" }); + expect(new DeviceStore(dir).gated).toBe(true); expect(store.match("not-a-token")).toBeNull(); // and a second store reading the same file knows the device expect(new DeviceStore(dir).match(paired!.token)?.label).toBe("My phone"); @@ -73,10 +93,61 @@ describe("DeviceStore", () => { expect(store.pair(second, "x", "drive")).toBeNull(); }); + it("keeps a first-pair retry open, preserves issued candidates across replaced codes, and bounds them", () => { + const root = mkdtempSync(join(dir, "retry-")); + const store = new DeviceStore(root); + const now = Date.now(); + const replaced = store.startPairing(now).code; + const firstCode = store.startPairing(now + 1).code; + expect(store.pair(replaced, "obsolete", "drive", now + 1)).toBeNull(); + const lost = store.pair(firstCode, "Lost cookie", "drive", now + 1)!; + const retry = store.pair(store.startPairing(now + 2).code, "Retry", "drive", now + 2)!; + expect(store.gated).toBe(false); + expect(store.list(null)).toEqual([]); + expect(store.match(retry.token, now + 3)).toEqual(retry.device); + expect(store.gated).toBe(true); + expect(store.match(lost.token, now + 4)).toBeNull(); + expect(store.list(null)).toHaveLength(1); + expect(store.revoke(retry.device.id)).toBe(true); + expect(store.gated).toBe(true); + expect(store.match(lost.token, now + 5)).toBeNull(); + expect(store.list(null)).toEqual([]); + expect(new DeviceStore(root).gated).toBe(true); + + const bounded = new DeviceStore(mkdtempSync(join(dir, "bounded-"))); + const issued: { token: string; device: { id: string; label: string; role: "drive" | "watch" } }[] = []; + for (let i = 0; i < 9; i += 1) { + const at = now + i * 2; + issued.push(bounded.pair(bounded.startPairing(at).code, `Device ${i}`, "drive", at)!); + } + expect(bounded.match(issued[0]!.token, now + 20)).toBeNull(); + expect(bounded.gated).toBe(false); + expect(bounded.list(null)).toEqual([]); + expect(bounded.match(issued[8]!.token, now + 20)).not.toBeNull(); + expect(bounded.gated).toBe(true); + }); + + it("expires unconfirmed first-pair cookies and loses them on restart without closing the gate", () => { + const root = mkdtempSync(join(dir, "candidate-expiry-")); + const store = new DeviceStore(root); + const started = Date.now(); + const paired = store.pair(store.startPairing(started).code, "Expired", "drive", started)!; + expect(store.match(paired.token, started + 10 * 60_000 + 1)).toBeNull(); + expect(store.gated).toBe(false); + expect(store.list(null)).toEqual([]); + expect(new DeviceStore(root).gated).toBe(false); + }); + it("renames, revokes, and marks the caller's device", () => { - const store = new DeviceStore(mkdtempSync(join(dir, "r-"))); + const root = mkdtempSync(join(dir, "r-")); + const store = new DeviceStore(root); const a = store.pair(store.startPairing().code, "A", "drive")!; + store.match(a.token); const b = store.pair(store.startPairing().code, "B", "drive")!; + const reloaded = new DeviceStore(root); + expect(reloaded.gated).toBe(true); + expect(reloaded.list(null).find((device) => device.id === b.device.id)?.last_seen_at).toBeNull(); + store.match(b.token); expect(store.list(a.device.id).map((d) => [d.label, d.current])).toEqual([["A", true], ["B", false]]); expect(store.update(b.device.id, { label: "Bee" })?.label).toBe("Bee"); expect(store.revoke(a.device.id)).toBe(true); @@ -86,7 +157,7 @@ describe("DeviceStore", () => { // revoking the last device does not reopen the gate, not even after a restart expect(store.revoke(b.device.id)).toBe(true); expect(store.gated).toBe(true); - expect(new DeviceStore(join(dir, "r-") === "" ? dir : (store as unknown as { path: string }).path.replace(/\/devices\.json$/, "")).gated).toBe(true); + expect(new DeviceStore(root).gated).toBe(true); }); }); @@ -119,6 +190,7 @@ it("reports failed registry writes without committing a pair, rename or revocati const root = mkdtempSync(join(dir, "write-failure-")); const store = new DeviceStore(root); const first = store.pair(store.startPairing().code, "Original", "drive")!; + store.match(first.token); let closed = false; store.onRevoke(first.device.id, () => { closed = true; }); const blocked = join(root, `devices.json.${process.pid}.tmp`); @@ -137,3 +209,19 @@ it("reports failed registry writes without committing a pair, rename or revocati expect(closed).toBe(true); expect(new DeviceStore(root).match(first.token)).toBeNull(); }); + +it("does not authorize a first-pair cookie when promotion cannot be persisted", () => { + const root = mkdtempSync(join(dir, "promotion-failure-")); + const store = new DeviceStore(root); + const candidate = store.pair(store.startPairing().code, "Pending", "drive")!; + const blocked = join(root, `devices.json.${process.pid}.tmp`); + mkdirSync(blocked); + expect(() => store.match(candidate.token)).toThrow(); + expect(store.gated).toBe(false); + expect(store.list(null)).toEqual([]); + expect(new DeviceStore(root).gated).toBe(false); + rmSync(blocked, { recursive: true }); + expect(store.match(candidate.token)).toEqual(candidate.device); + expect(store.gated).toBe(true); + expect(new DeviceStore(root).match(candidate.token)).toEqual(candidate.device); +}); diff --git a/server/devices.ts b/server/devices.ts index e2a00876e..f3d7a6668 100644 --- a/server/devices.ts +++ b/server/devices.ts @@ -18,6 +18,7 @@ import { sameOrigin } from "./machine-security.ts"; const CODE_TTL_MS = 10 * 60_000; const CODE_ATTEMPTS = 5; const LAST_SEEN_WRITE_MS = 60_000; +const UNCONFIRMED_FIRST_PAIR_LIMIT = 8; const LABEL_MAX = 48; interface StoredDevice { @@ -29,6 +30,11 @@ interface StoredDevice { last_seen_at: string | null; } +interface UnconfirmedFirstPair { + device: StoredDevice; + expires: number; +} + export interface PairingCode { code: string; expires_at: string; @@ -70,6 +76,8 @@ export class DeviceStore { /** when the first device was paired: from then on, only this PC, the owner's login, a device or the token get in */ private gateClosedAt: string | null = null; private pending: { code: string; expires: number; attempts: number } | null = null; + /** First-pair cookies are not devices, and do not close the gate, until one returns. */ + private unconfirmedFirstPairs: UnconfirmedFirstPair[] = []; private readonly path: string; private readonly revocations = new Map void>>(); readonly error: string | null = null; @@ -128,6 +136,7 @@ export class DeviceStore { /** A fresh six-digit code, replacing any code still pending. */ startPairing(now = Date.now()): PairingCode { this.assertWritable(); + this.pruneUnconfirmedFirstPairs(now); const code = String(randomInt(0, 1_000_000)).padStart(6, "0"); this.pending = { code, expires: now + CODE_TTL_MS, attempts: 0 }; return { code, expires_at: new Date(this.pending.expires).toISOString() }; @@ -136,6 +145,7 @@ export class DeviceStore { /** `{ token }` when the code is right; null when it is wrong, spent or expired. */ pair(code: string, label: string, role: DeviceRole, now = Date.now()): { token: string; device: DeviceMatch } | null { this.assertWritable(); + this.pruneUnconfirmedFirstPairs(now); const pending = this.pending; if (pending === null || now > pending.expires) { this.pending = null; return null; } if (!same(code.replace(/\D/g, ""), pending.code)) { @@ -145,6 +155,12 @@ export class DeviceStore { } const token = randomBytes(32).toString("hex"); const device: StoredDevice = { id: randomBytes(8).toString("hex"), label, role, token_hash: hash(token), created_at: new Date(now).toISOString(), last_seen_at: null }; + if (!this.gated) { + this.pending = null; + this.unconfirmedFirstPairs.push({ device, expires: now + CODE_TTL_MS }); + if (this.unconfirmedFirstPairs.length > UNCONFIRMED_FIRST_PAIR_LIMIT) this.unconfirmedFirstPairs.shift(); + return { token, device: { id: device.id, label: device.label, role: device.role } }; + } const next = [...this.devices, device]; const gate = this.gateClosedAt ?? device.created_at; this.save(next, gate); @@ -156,8 +172,25 @@ export class DeviceStore { /** The device a cookie token belongs to, noting when it was last seen (at most once a minute on disk). */ match(token: string | undefined, now = Date.now()): DeviceMatch | null { + this.pruneUnconfirmedFirstPairs(now); if (!token) return null; const digest = hash(token); + const candidateIndex = this.unconfirmedFirstPairs.findIndex(({ device }) => same(digest, device.token_hash)); + if (candidateIndex !== -1) { + const candidate = this.unconfirmedFirstPairs[candidateIndex]!.device; + const confirmed = { ...candidate, last_seen_at: new Date(now).toISOString() }; + const next = [...this.devices, confirmed]; + const gate = this.gateClosedAt ?? confirmed.created_at; + // This is an authorization write, not last-seen telemetry: do not match the cookie + // unless the device and closed gate reached disk together. + this.save(next, gate); + this.devices = next; + this.gateClosedAt = gate; + // Once one first-pair cookie closes the gate, every sibling candidate is stale: + // subsequent devices must be issued through the gated pairing flow. + this.unconfirmedFirstPairs = []; + return { id: confirmed.id, label: confirmed.label, role: confirmed.role }; + } for (const device of this.devices) { if (!same(digest, device.token_hash)) continue; const seen = device.last_seen_at === null ? 0 : Date.parse(device.last_seen_at); @@ -188,8 +221,12 @@ export class DeviceStore { this.assertWritable(); const next = this.devices.filter((d) => d.id !== id); if (next.length === this.devices.length) return false; - this.save(next); + // A valid older/manual registry can have devices without a gate timestamp. Removing its + // last device must still leave durable evidence that this store was once gated. + const gate = this.gateClosedAt ?? this.devices[0]?.created_at ?? null; + this.save(next, gate); this.devices = next; + this.gateClosedAt = gate; const listeners = this.revocations.get(id); this.revocations.delete(id); for (const close of listeners ?? []) close(); @@ -198,6 +235,10 @@ export class DeviceStore { has(id: string): boolean { return this.devices.some((device) => device.id === id); } + private pruneUnconfirmedFirstPairs(now: number): void { + this.unconfirmedFirstPairs = this.unconfirmedFirstPairs.filter(({ expires }) => now <= expires); + } + private save(devices = this.devices, gateClosedAt = this.gateClosedAt): void { writeJsonPrivate(this.path, { gate_closed_at: gateClosedAt, devices }); } diff --git a/server/find.contract.test.ts b/server/find.contract.test.ts index 9cef189ce..045000350 100644 --- a/server/find.contract.test.ts +++ b/server/find.contract.test.ts @@ -171,7 +171,12 @@ describe("POST /api/pane/find", () => { }); expect(response.status).toBe(409); expect((await response.json() as ApiError).error.code).toBe("stale_content"); - expect(await paneScrollInfo(paneId)).toEqual(afterOutput); + const afterResponse = await paneScrollInfo(paneId); + // The substring receipt can precede cat's remaining output. History growth + // raises both bottom-relative offsets without moving the viewport's top row. + expect(afterResponse && afterResponse.max_offset_from_bottom - afterResponse.offset_from_bottom) + .toBe(afterOutput && afterOutput.max_offset_from_bottom - afterOutput.offset_from_bottom); + expect(afterResponse?.viewport_rows).toBe(afterOutput?.viewport_rows); }); } diff --git a/server/fixtures/omo-pending-5.1.30-extension-tail.txt b/server/fixtures/omo-pending-5.1.30-extension-tail.txt new file mode 100644 index 000000000..1fc5056ad --- /dev/null +++ b/server/fixtures/omo-pending-5.1.30-extension-tail.txt @@ -0,0 +1,6 @@ + ▶ example workflow running wave 1/2 0/4 done, 1 running, 2 failed + ▶ question-parser · category:deep-low · Checking captured question cards · 7m + 53s + ◌ verify · category:quick · waiting 21m 59s + ✗ scroll-check · category:deep-low · x2 · 1m 10s + ✗ answer-check · category:deep-low · x2 · 1m 10s diff --git a/server/fixtures/omo-pending-5.1.30-one-narrow.txt b/server/fixtures/omo-pending-5.1.30-one-narrow.txt new file mode 100644 index 000000000..3da9901c6 --- /dev/null +++ b/server/fixtures/omo-pending-5.1.30-one-narrow.txt @@ -0,0 +1,45 @@ + 영 안 됨, 카드가 안 사라짐, 에러 표시 + "}]}],"waitForAnswer":false}. Then end your + turn. + + + I read this as a request to ask one + question - I'll ask it without waiting for + the reply. I'll stop when the question has + been sent. + + + [답변창 증상] answer later + pending + Question accepted; the answer will arrive + as a user message. + + + Thought: 866ms + Since the tool call completed the work, I + should wrap up and give a brief handoff in + Korean. + + Ask: 질문 1개를 지정된 인자 그대로 보내기 - + wanted: 답변을 기다리지 않고 질문만 띄우기. + For you: 질문을 보냈고 아직 열려 있습니다. + 답변창이 어떻게 이상하게 보였는지 고르시거 + 나 직접 적어 주세요. 스크린샷 경로를 주셔도 + 됩니다. Now: 없음. Next: 답변이 오면 그 증 + 상을 기준으로 다시 재현해 보겠습니다. + + TPS 171.5 tok/s. Cache hit 11.4%, 4.2s + +? Question pending (1 unanswered) · 30m + 답변창 증상 — omo 질문 답변창을 데모 fix... +[ 카드 대신 텍스트만 ] [ 내용이 틀림 ] +[ 레이아웃 깨짐 ] [ 답변 후 동작 이상 ] +[ own answer… ] +click an option · shift+drag to select text +enter or alt+up to answer · /answer · or just +type your reply +───────────────────────────────────────────── +❯ +───────────────────────────────────────────── +… • 13K/1M (1.3%) (auto) claude-opus-5-5:low +(😺 OmO Native by Q Kim) diff --git a/server/fixtures/omo-pending-5.1.30-one-wide.txt b/server/fixtures/omo-pending-5.1.30-one-wide.txt new file mode 100644 index 000000000..4f15b33b6 --- /dev/null +++ b/server/fixtures/omo-pending-5.1.30-one-wide.txt @@ -0,0 +1,40 @@ + Settings: settings.json (JSON) + + + Call ask_user_question once with exactly these arguments: {"questions":[{"question":"omo 질문 답변창을 데모 fixture(2 + 개 질문 폼, 데스크톱 1280·폰 390 폭)로 재현했는데 깨진 곳이 안 보였습니다. '이상하게 나온다'는 게 어떤 모습이었나요? ( + 스크린샷 경로나 설명을 자유 입력으로 주셔도 됩니다)","header":"답변창 증상","multiSelect":true,"options":[{"label":"카 + 드 대신 텍스트만","description":"선택지 카드가 안 뜨고 일반 메시지/터미널 텍스트처럼 보임"},{"label":"내용이 틀림 + ","description":"질문/선택지/제목이 잘못되거나 중복, 다른 질문이 표시됨"},{"label":"레이아웃 깨짐","description":"잘림 + , 겹침, 스크롤 불가, 입력창 가림 등"},{"label":"답변 후 동작 이상","description":"선택해도 반영 안 됨, 카드가 안 사라 + 짐, 에러 표시"}]}],"waitForAnswer":false}. Then end your turn. + + + I read this as a request to ask one question - I'll ask it without waiting for the reply. I'll stop when the question + has been sent. + + + [답변창 증상] answer later + pending + Question accepted; the answer will arrive as a user message. + + + Thought: 866ms + Since the tool call completed the work, I should wrap up and give a brief handoff in Korean. + + Ask: 질문 1개를 지정된 인자 그대로 보내기 - wanted: 답변을 기다리지 않고 질문만 띄우기. For you: 질문을 보냈고 아직 열 + 려 있습니다. 답변창이 어떻게 이상하게 보였는지 고르시거나 직접 적어 주세요. 스크린샷 경로를 주셔도 됩니다. Now: 없음. + Next: 답변이 오면 그 증상을 기준으로 다시 재현해 보겠습니다. + + TPS 171.5 tok/s. Cache hit 11.4%, 4.2s + +? Question pending (1 unanswered) · 30m + 답변창 증상 — omo 질문 답변창을 데모 fixture(2개 질문 폼, 데스크톱 1280·폰 390 폭)로 재현했는데 깨진 곳이 안 보였습... +[ 카드 대신 텍스트만 ] [ 내용이 틀림 ] [ 레이아웃 깨짐 ] [ 답변 후 동작 이상 ] [ own answer… ] +click an option · shift+drag to select text +enter or alt+up to answer · /answer · or just type your reply +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯ +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +/tmp/omo-pending-scratch-Zdjrjx • omo 답변창 증상 질문하기 • … • 13K/1M (1.3%) (auto) (anthropic) claude-opus-5-5:low +(😺 OmO Native by Q Kim) diff --git a/server/fixtures/omo-pending-5.1.30-one.jsonl b/server/fixtures/omo-pending-5.1.30-one.jsonl new file mode 100644 index 000000000..64b48b84c --- /dev/null +++ b/server/fixtures/omo-pending-5.1.30-one.jsonl @@ -0,0 +1,3 @@ +{"type":"message","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01FGktAAJHxUQrsm5n8bWXNG","name":"ask_user_question","arguments":{"questions":[{"question":"omo 질문 답변창을 데모 fixture(2개 질문 폼, 데스크톱 1280·폰 390 폭)로 재현했는데 깨진 곳이 안 보였습니다. '이상하게 나온다'는 게 어떤 모습이었나요? (스크린샷 경로나 설명을 자유 입력으로 주셔도 됩니다)","header":"답변창 증상","multiSelect":true,"options":[{"label":"카드 대신 텍스트만","description":"선택지 카드가 안 뜨고 일반 메시지/터미널 텍스트처럼 보임"},{"label":"내용이 틀림","description":"질문/선택지/제목이 잘못되거나 중복, 다른 질문이 표시됨"},{"label":"레이아웃 깨짐","description":"잘림, 겹침, 스크롤 불가, 입력창 가림 등"},{"label":"답변 후 동작 이상","description":"선택해도 반영 안 됨, 카드가 안 사라짐, 에러 표시"}]}],"waitForAnswer":false}}]}} +{"type":"custom","customType":"ask-user:question","data":{"requestId":"toolu_01FGktAAJHxUQrsm5n8bWXNG","headers":["답변창 증상"]},"id":"3fd788ae","parentId":"f6d132d3","timestamp":"2026-10-12T08:03:57.030Z"} +{"type":"message","message":{"role":"toolResult","toolCallId":"toolu_01FGktAAJHxUQrsm5n8bWXNG","toolName":"ask_user_question","content":[{"type":"text","text":"Question accepted; the answer will arrive as a user message."}],"details":{"accepted":true,"requestId":"toolu_01FGktAAJHxUQrsm5n8bWXNG","status":"pending"},"isError":false,"timestamp":1791792237037}} diff --git a/server/fixtures/omo-pending-5.1.30-two-narrow.txt b/server/fixtures/omo-pending-5.1.30-two-narrow.txt new file mode 100644 index 000000000..d15401e89 --- /dev/null +++ b/server/fixtures/omo-pending-5.1.30-two-narrow.txt @@ -0,0 +1,45 @@ + 음", "문제 없음". Second header "화면 크기 + ", question "어떤 화면에서 확인했나요?", + options "휴대폰", "데스크톱". Then end your + turn. + + + Thought: 9ms + I need the header within 12 characters, so + "답변창 증상" works well, and I should put + the routing line first, written in Korean. + + 질문을 보내는 요청으로 이해했습니다. 질문 + 두 개를 기다리지 않는 방식으로 한 번 보내고 + , 질문이 열리면 이번 턴을 마칩니다. + + + [답변창 증상] [화면 크기] answer later + pending + Question accepted; the answer will arrive + as a user message. + + + Ask: 질문 두 개를 대기 없이 한 번 보내기 - + wanted: 증상과 화면 크기 질문 열기. For + you: 질문 두 개("답변창 증상", "화면 크기") + 를 기다리지 않는 방식으로 한 번 보냈고, 정 + 상적으로 접수됐습니다. 답변은 나중에 메시지 + 로 도착합니다. Now: 없음. Next: 없음. + + TPS 549.4 tok/s. Cache hit 6.0%, 1.0s + +? Question pending (2 unanswered) · 30m + 답변창 증상 — 휴대폰에서 질문에 답변할 ... +[ 레이아웃 깨짐 ] [ 답변 후 동작 이상 ] +[ 선택지가 보이지 않음 ] [ 문제 없음 ] +[ own answer… ] ++1 more question +click an option · shift+drag to select text +enter or alt+up to answer · /answer · or just +type your reply +───────────────────────────────────────────── +❯ +───────────────────────────────────────────── +… • 12K/1M (1.2%) (auto) claude-opus-5-5:low +(😺 OmO Native by Q Kim) diff --git a/server/fixtures/omo-pending-5.1.30-two-wide.txt b/server/fixtures/omo-pending-5.1.30-two-wide.txt new file mode 100644 index 000000000..ceff54e4a --- /dev/null +++ b/server/fixtures/omo-pending-5.1.30-two-wide.txt @@ -0,0 +1,40 @@ + + Settings: settings.json (JSON) + + + Call ask_user_question exactly once with waitForAnswer false, with exactly two questions. First header "답변창 증상", + question "휴대폰에서 질문에 답변할 때 어떤 증상이 나타나나요?", options "레이아웃 깨짐", "답변 후 동작 이상", "선택지 + 가 보이지 않음", "문제 없음". Second header "화면 크기", question "어떤 화면에서 확인했나요?", options "휴대폰", "데스 + 크톱". Then end your turn. + + + Thought: 9ms + I need the header within 12 characters, so "답변창 증상" works well, and I should put the routing line first, written + in Korean. + + 질문을 보내는 요청으로 이해했습니다. 질문 두 개를 기다리지 않는 방식으로 한 번 보내고, 질문이 열리면 이번 턴을 마칩니 + 다. + + + [답변창 증상] [화면 크기] answer later + pending + Question accepted; the answer will arrive as a user message. + + + Ask: 질문 두 개를 대기 없이 한 번 보내기 - wanted: 증상과 화면 크기 질문 열기. For you: 질문 두 개("답변창 증상", "화 + 면 크기")를 기다리지 않는 방식으로 한 번 보냈고, 정상적으로 접수됐습니다. 답변은 나중에 메시지로 도착합니다. Now: 없음 + . Next: 없음. + + TPS 549.4 tok/s. Cache hit 6.0%, 1.0s + +? Question pending (2 unanswered) · 30m + 답변창 증상 — 휴대폰에서 질문에 답변할 때 어떤 증상이 나타나나요? +[ 레이아웃 깨짐 ] [ 답변 후 동작 이상 ] [ 선택지가 보이지 않음 ] [ 문제 없음 ] [ own answer… ] ++1 more question +click an option · shift+drag to select text +enter or alt+up to answer · /answer · or just type your reply +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯ +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +/tmp/omo-pending-scratch-Zdjrjx • … • 12K/1M (1.2%) (auto) (anthropic) claude-opus-5-5:low +(😺 OmO Native by Q Kim) diff --git a/server/fixtures/omo-pending-5.1.30-two.jsonl b/server/fixtures/omo-pending-5.1.30-two.jsonl new file mode 100644 index 000000000..8948c35c1 --- /dev/null +++ b/server/fixtures/omo-pending-5.1.30-two.jsonl @@ -0,0 +1,3 @@ +{"type":"message","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_013p8rDTkc9twBXHcnxtbaSv","name":"ask_user_question","arguments":{"questions":[{"header":"답변창 증상","question":"휴대폰에서 질문에 답변할 때 어떤 증상이 나타나나요?","multiSelect":false,"options":[{"label":"레이아웃 깨짐"},{"label":"답변 후 동작 이상"},{"label":"선택지가 보이지 않음"},{"label":"문제 없음"}]},{"header":"화면 크기","question":"어떤 화면에서 확인했나요?","multiSelect":false,"options":[{"label":"휴대폰"},{"label":"데스크톱"}]}],"waitForAnswer":false}}]}} +{"type":"custom","customType":"ask-user:question","data":{"requestId":"toolu_013p8rDTkc9twBXHcnxtbaSv","headers":["답변창 증상","화면 크기"]},"id":"81069b2a","parentId":"077d12e2","timestamp":"2026-10-12T08:02:59.440Z"} +{"type":"message","message":{"role":"toolResult","toolCallId":"toolu_013p8rDTkc9twBXHcnxtbaSv","toolName":"ask_user_question","content":[{"type":"text","text":"Question accepted; the answer will arrive as a user message."}],"details":{"accepted":true,"requestId":"toolu_013p8rDTkc9twBXHcnxtbaSv","status":"pending"},"isError":false,"timestamp":1791792179446}} diff --git a/server/gjc-runtime.test.ts b/server/gjc-runtime.test.ts index 3d4fc1fd4..0c1db1468 100644 --- a/server/gjc-runtime.test.ts +++ b/server/gjc-runtime.test.ts @@ -1,9 +1,9 @@ import { expect, it } from "bun:test"; -import { appendFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, win32 } from "node:path"; import { forgetTranscriptState } from "./conversation.ts"; -import { boundGjcTranscript, gjcAnswerAmong, gjcSessionTitle, gjcStatusTitle, gjcTitles, gjcDisplayCandidates, gjcPidUnderShell, gjcSessionFile, isGjcProcess, matchGjcTranscript, parseGjcPs, recentProcessTable, storeRelative, terminalBreadcrumb } from "./gjc-runtime.ts"; +import { gjcPidUnderShell, gjcSessionFile, gjcTranscriptForPane, isGjcProcess, parseGjcPs, recentProcessTable, storeRelative, terminalBreadcrumb, validateGjcSdkTranscript } from "./gjc-runtime.ts"; // Session paths come back canonical and the store root is passed in canonical; macOS's tmpdir is a symlink into /private. const tempDir = (prefix: string) => realpathSync(mkdtempSync(join(tmpdir(), prefix))); @@ -28,7 +28,6 @@ it("reads macOS terminal/process identity without /proc", () => { it("validates breadcrumbs against process age, canonical cwd and the native session store", () => { const home = tempDir("gjc-breadcrumb-"); try { - // GJC's layout: one store directory per project under sessions/ const store = join(home, ".gjc/agent/sessions/v2-project"); const markers = join(home, ".gjc/agent/terminal-sessions"); mkdirSync(store, { recursive: true }); mkdirSync(markers); @@ -61,7 +60,6 @@ it("reads a breadcrumb left on a subagent's file as the session that ran it", () expect(terminalBreadcrumb(join(home, ".gjc/agent"), home, "pts-3", 0)).toBe(session); expect(gjcSessionFile(root, session)).toBe(session); expect(gjcSessionFile(root, subagent)).toBe(session); - // a subagent whose session file is gone, or any other depth, stands for nothing rmSync(session); expect(terminalBreadcrumb(join(home, ".gjc/agent"), home, "pts-3", 0)).toBeNull(); expect(gjcSessionFile(root, subagent)).toBeNull(); @@ -75,9 +73,7 @@ it("reads a store on a Windows PC, where paths come with backslashes", () => { const root = "C:\\Users\\u\\.gjc\\agent\\sessions"; const session = `${root}\\v2-project\\2026-10-01_session.jsonl`; expect(gjcSessionFile(root, session, win32)).toBe(session); - // what gjcDisplayCandidates asks of every file it lists expect(storeRelative(root, session, win32)).toEqual(["v2-project", "2026-10-01_session.jsonl"]); - // the drive letter's case is not a different place there expect(gjcSessionFile(root, `c${session.slice(1)}`, win32)).toBe(`c${session.slice(1)}`); for (const outside of [ `${root}-evil\\v2-project\\session.jsonl`, @@ -92,7 +88,6 @@ it("reads a store on a Windows PC, where paths come with backslashes", () => { } expect(gjcSessionFile(root, `${root}\\top.jsonl`, win32)).toBeNull(); expect(gjcSessionFile(root, `${root}\\v2-project\\notes.txt`, win32)).toBeNull(); - // a name that only starts with two dots is a name, not the way out expect(storeRelative(root, `${root}\\..project\\session.jsonl`, win32)).toEqual(["..project", "session.jsonl"]); }); @@ -111,235 +106,255 @@ it("finds gjc under a Windows pane's shell, the only process herdr names there", { pid: 100, parent: 4, path: powershell, commandLine: "powershell.exe" }, { pid: 101, parent: 4, path: powershell, commandLine: "powershell.exe" }, { pid: 150, parent: 100, path: "C:\\Windows\\System32\\conhost.exe", commandLine: "conhost.exe" }, - // gjc's own helper, a child of the session's process (seen on a real PC) { pid: 300, parent: 200, path: gjc, commandLine: `${gjc} sdk broker-internal` }, { pid: 200, parent: 100, path: gjc, commandLine: `"${gjc}" --resume` }, ]; const table = async () => rows; expect(await gjcPidUnderShell(100, "win32", table)).toEqual({ pid: 200, started: null }); - // another pane's shell on the same PC does not run it expect(await gjcPidUnderShell(101, "win32", table)).toBeNull(); expect(await gjcPidUnderShell(undefined, "win32", table)).toBeNull(); - // the process's start comes with it: a number alone is handed to the next process expect(await gjcPidUnderShell(100, "win32", async () => rows.map((row) => row.pid === 200 ? { ...row, started: 7 } : row))).toEqual({ pid: 200, started: 7 }); - // a table that could not be read says nothing about gjc expect(await gjcPidUnderShell(100, "win32", async () => [])).toBeUndefined(); - // elsewhere herdr's foreground processes are the answer and the table is never asked let asked = false; expect(await gjcPidUnderShell(100, "linux", async () => { asked = true; return rows; })).toBeNull(); expect(asked).toBe(false); }); -it("reads the session title from gjc's status line", () => { - // as gjc 0.16.4 draws it on a Windows PC: the status line sits right over the message box - const BOX = "\n╭──────╮\n│ > Type your message... │\n╰──────╯\n"; - const line = (middle: string, dir = "~\\herdr-qa") => ` ⬢ sonnet-5 · ◒ med · 1.8% / 📁 ${dir} ──────── ${middle} / v0.16.4`; - const status = (middle: string, dir?: string) => ` user\n ok\n${line(middle, dir)}${BOX}`; - expect(gjcStatusTitle(status("Simple Ok Reply / ⤴ 0.3/s / $0.04 (sub)"))).toBe("Simple Ok Reply"); - // right after /new the session has no title yet - expect(gjcStatusTitle(status("(sub)"))).toBeNull(); - // a title holds what it holds: a slash, parentheses, a word like a version or a price - expect(gjcStatusTitle(status("Plan / Fix / ⤴ 1.0/s / $0.10 (sub)"))).toBe("Plan / Fix"); - expect(gjcStatusTitle(status("(WIP) Fix / $0.04 (sub)"))).toBe("(WIP) Fix"); - expect(gjcStatusTitle(status("v2 Migration / (sub)"))).toBe("v2 Migration"); - expect(gjcStatusTitle(status("(draft) / (sub)"))).toBe("(draft)"); - expect(gjcStatusTitle(status("Ship / (draft) / ⤴ 2.0/s / $1.20 (sub)"))).toBe("Ship / (draft)"); - // a second rule in the line (in the folder's name, or the title's) leaves nothing to tell them by - expect(gjcStatusTitle(status("Simple Ok Reply / (sub)", "~\\a ──── b"))).toBeUndefined(); - expect(gjcStatusTitle(status("Foo ──── Bar / (sub)"))).toBeUndefined(); - // an arrow in a title is not gjc's speed - expect(gjcStatusTitle(status("Ship / ⤴ Thoughts / (sub)"))).toBe("Ship / ⤴ Thoughts"); - // a title cut short by a narrow pane says nothing - expect(gjcStatusTitle(status("Herdr GJC Binding Che… / ⤴ 13.6/s / $0.04 (sub)"))).toBeUndefined(); - // a menu stands where the message box does (/resume): nothing to read, whatever is printed above - expect(gjcStatusTitle(" Resume Session\n> \n❯ Simple Ok Reply\n")).toBeUndefined(); - const printed = ` gajae\n${line("Printed Other / (sub)")}${BOX} more of the answer\n`; - expect(gjcStatusTitle(printed)).toBeUndefined(); - expect(gjcStatusTitle(printed + line("Actual Session / (sub)") + "\n Resume Session\n> \n❯ Printed Other\n")).toBeUndefined(); - // the same printed above the real status line and box is text - expect(gjcStatusTitle(printed + status("Real One / (sub)"))).toBe("Real One"); -}); +it("validates an SDK-selected main transcript by canonical path and exact first-line identity", () => { + const home = tempDir("gjc-sdk-file-"); + try { + const cwd = join(home, "work"), otherCwd = join(home, "other"); + const agentDir = join(home, ".gjc", "agent"), project = join(agentDir, "sessions", "v2-project"); + mkdirSync(cwd); mkdirSync(otherCwd); mkdirSync(project, { recursive: true }); + const sessionId = "sdk-session-1"; + const header = (id: string, sessionCwd: string) => JSON.stringify({ type: "session", id, cwd: sessionCwd }) + "\n"; + const bind = (path: string, id = sessionId) => ({ sessionId: id, pid: 200, path, hostIncarnation: "host", endpointGeneration: 1 }); + const good = join(project, "main.jsonl"); + writeFileSync(good, header(sessionId, cwd)); + expect(validateGjcSdkTranscript(bind(good), cwd, agentDir)).toBe(realpathSync(good)); + + const wrongId = join(project, "wrong-id.jsonl"); + writeFileSync(wrongId, header("another-session", cwd)); + expect(validateGjcSdkTranscript(bind(wrongId), cwd, agentDir)).toBeNull(); + const wrongCwd = join(project, "wrong-cwd.jsonl"); + writeFileSync(wrongCwd, header(sessionId, otherCwd)); + expect(validateGjcSdkTranscript(bind(wrongCwd), cwd, agentDir)).toBeNull(); + + const outside = join(home, "outside.jsonl"); + writeFileSync(outside, header(sessionId, cwd)); + expect(validateGjcSdkTranscript(bind(outside), cwd, agentDir)).toBeNull(); + const symlink = join(project, "outside-link.jsonl"); + symlinkSync(outside, symlink); + expect(validateGjcSdkTranscript(bind(symlink), cwd, agentDir)).toBeNull(); -it("reads which sessions of the folder can be the one on screen", () => { - const titles: Record = { a: "Ship", b: "Ship", c: "Other", d: null, big: undefined }; - const titleOf = (path: string) => titles[path]; - // one file with the title; an answer is matched among the files carrying it - expect(gjcTitles(["a", "c", "d"], "Ship", titleOf)).toMatchObject({ titled: "a", titledCount: 1, among: ["a"] }); - // two share it: an answer of another session's file (c holds a copy of a's answer) is not asked - expect(gjcTitles(["a", "b", "c"], "Ship", titleOf)).toMatchObject({ titled: null, titledCount: 2, among: ["a", "b"] }); - // none carries it yet: only a session without a title can be the one - expect(gjcTitles(["c", "d"], "Fresh", titleOf)).toMatchObject({ titled: null, titledCount: 0, among: ["d"] }); - // a history not read to its end yet: nothing is decided by counting one, but two that carry it are two - expect(gjcTitles(["a", "big"], "Ship", titleOf)).toMatchObject({ titled: null, titledCount: -1, among: ["a"], unread: ["big"] }); - expect(gjcTitles(["a", "b", "big"], "Ship", titleOf)).toMatchObject({ titled: null, titledCount: 2, among: ["a", "b"] }); - // and an unread one is no untitled session: an answer is not matched in it - expect(gjcTitles(["c", "d", "big"], "Fresh", titleOf)).toMatchObject({ titledCount: -1, among: ["d"] }); - expect(gjcTitles(["a", "d", "big"], null, titleOf)).toMatchObject({ among: ["d"] }); - // no title in the status line: a titled session is not the one running - expect(gjcTitles(["a", "d"], null, titleOf)).toMatchObject({ titledCount: 0, among: ["d"] }); - // a long history listed first does not use up the look before a short session is read - const sizes: Record = { archive: 130 * 1024 * 1024, target: 3000 }; - const read: string[] = []; - const within = (path: string, budget: { bytes: number }) => { read.push(path); if (budget.bytes <= 0) return undefined; budget.bytes -= Math.min(budget.bytes, sizes[path]!); return path === "target" ? "Target" : undefined; }; - expect(gjcTitles(["archive", "target"], "Target", within, (path) => sizes[path]!)).toMatchObject({ among: ["target"], titledCount: -1 }); - expect(read).toEqual(["target", "archive"]); - // no status line: every file is asked, and none is read for a title - expect(gjcTitles(["a", "d"], undefined, () => { throw new Error("read"); })).toMatchObject({ among: ["a", "d"] }); + const subagent = join(project, sessionId, "worker.jsonl"); + mkdirSync(join(project, sessionId)); + writeFileSync(subagent, header(sessionId, cwd)); + expect(validateGjcSdkTranscript(bind(subagent), cwd, agentDir)).toBeNull(); + const malformed = join(project, "malformed.jsonl"); + writeFileSync(malformed, "{not-json}\n"); + expect(validateGjcSdkTranscript(bind(malformed), cwd, agentDir)).toBeNull(); + const longHeader = join(project, "long-header.jsonl"); + writeFileSync(longHeader, JSON.stringify({ type: "session", id: sessionId, cwd, padding: "x".repeat(64 * 1024) }) + "\n"); + expect(validateGjcSdkTranscript(bind(longHeader), cwd, agentDir)).toBeNull(); + } finally { rmSync(home, { recursive: true, force: true }); } }); -it("keeps a Windows pane on the session its screen once showed, while the same gjc runs there", async () => { - forgetTranscriptState(); - const file = "C:\\s\\one.jsonl", other = "C:\\s\\two.jsonl"; - const gjc = { pid: 200, started: 1000 }; - const bind = (pane: string, process: typeof gjc | null | undefined, path: string | null) => - boundGjcTranscript(pane, process, async () => ({ path, title: undefined, titled: null, titledCount: 0, titles: new Map() })); - // a running gjc alone names no session: the first answer needs the screen - expect(await bind("w1:p1", gjc, null)).toBeNull(); - expect(await bind("w1:p1", gjc, file)).toBe(file); - // a long answer pushed every answer's tail off the screen - expect(await bind("w1:p1", gjc, null)).toBe(file); - expect(await bind("w1:p2", gjc, null)).toBeNull(); - // the same process shows another session (/resume): the screen wins - expect(await bind("w1:p1", gjc, other)).toBe(other); - expect(await bind("w1:p1", gjc, null)).toBe(other); - // a different gjc in the pane, and the old one's number coming back, start over - expect(await bind("w1:p1", { pid: 201, started: 1100 }, null)).toBeNull(); - expect(await bind("w1:p1", gjc, null)).toBeNull(); - // gjc gone from the pane: nothing is answered, whatever the screen still shows - expect(await bind("w1:p1", gjc, file)).toBe(file); - expect(await bind("w1:p1", null, file)).toBeNull(); - expect(await bind("w1:p1", gjc, null)).toBeNull(); - forgetTranscriptState(); +it("re-reads pane process info and birth identity before accepting the SDK binding", async () => { + const home = tempDir("gjc-sdk-race-"); + try { + const cwd = join(home, "work"), agentDir = join(home, ".gjc", "agent"); + const project = join(agentDir, "sessions", "v2-project"), path = join(project, "main.jsonl"); + mkdirSync(cwd); mkdirSync(project, { recursive: true }); + const sessionId = "sdk-session-race"; + writeFileSync(path, JSON.stringify({ type: "session", id: sessionId, cwd }) + "\n"); + const binding = { sessionId, pid: 200, path, hostIncarnation: "host", endpointGeneration: 1 }; + const info = (pid: number) => ({ process_info: { foreground_processes: [{ pid, argv: ["/usr/bin/gjc"] }] } }); + let reads = 0, births = 0; + const options = { + platform: "linux", + processInfo: async () => { reads += 1; return info(reads === 1 ? 200 : 200); }, + processBirth: () => { births += 1; return births === 1 ? 1000 : 1000; }, + sdkTranscript: async (pids: readonly number[]) => { expect(pids).toEqual([200]); return binding; }, + }; + expect(await gjcTranscriptForPane("pane", cwd, home, options)).toBe(realpathSync(path)); + expect(reads).toBe(2); + expect(births).toBe(2); + + reads = 0; births = 0; + expect(await gjcTranscriptForPane("pane", cwd, home, { + ...options, + processInfo: async () => { reads += 1; return info(reads === 1 ? 200 : 201); }, + })).toBeNull(); + reads = 0; births = 0; + expect(await gjcTranscriptForPane("pane", cwd, home, { + ...options, + processInfo: async () => { reads += 1; return info(200); }, + processBirth: () => { births += 1; return births === 1 ? 1000 : 2000; }, + })).toBeNull(); + // macOS: one process read either side of a second boundary is still that process + births = 0; + expect(await gjcTranscriptForPane("pane", cwd, home, { + ...options, + platform: "darwin", + processBirth: () => { births += 1; return births === 1 ? 1900 : 2100; }, + })).toBe(realpathSync(path)); + births = 0; + expect(await gjcTranscriptForPane("pane", cwd, home, { + ...options, + platform: "darwin", + processBirth: () => { births += 1; return births === 1 ? 1000 : 2500; }, + })).toBeNull(); + reads = 0; + expect(await gjcTranscriptForPane("pane", cwd, home, { + ...options, + processInfo: async () => ++reads === 1 ? info(200) : null, + })).toBeNull(); + } finally { rmSync(home, { recursive: true, force: true }); } }); -it("follows gjc's own session title across /new and /resume on a Windows pane", async () => { - forgetTranscriptState(); - const first = "first", fresh = "fresh", twin = "twin", untitled = "untitled", big = "big"; - const titles: Record = { [first]: "Binding Check", [fresh]: "Simple Ok Reply", [twin]: "Simple Ok Reply", [untitled]: null, [big]: undefined }; - const gjc = { pid: 200, started: 1000 }; - // the screen as gjcTranscriptForPane reads it; `answer` is the file an answer on screen is of - const bind = (answer: string | null, title: string | null | undefined, files: string[] = [first, fresh]) => { - const read = gjcTitles(files, title, (path) => titles[path]); - return boundGjcTranscript("w1:p1", gjc, async () => ({ path: answer !== null && read.among.includes(answer) ? answer : null, title, titled: read.titled, titledCount: read.titledCount, titles: read.titles })); - }; - expect(await bind(first, "Binding Check")).toBe(first); - // /new: the status line shows no title, so the session running is not the titled one. The chat - // shows none until it can tell (measured on a real PC: it stayed on the old conversation), also - // while the old session's answer is still on screen - expect(await bind(null, null)).toBeNull(); - expect(await bind(first, null)).toBeNull(); - // a one-word answer: gjc titles the new session, and one file carries that title - expect(await bind(null, "Simple Ok Reply")).toBe(fresh); - // an answer of another session pasted into this one is text, titled or not - expect(await bind(first, "Simple Ok Reply")).toBe(fresh); - expect(await bind(untitled, "Simple Ok Reply", [first, fresh, untitled])).toBe(fresh); - // /resume back to the first session with none of its answers on screen, and to the one already shown - expect(await bind(null, "Binding Check")).toBe(first); - expect(await bind(null, "Binding Check")).toBe(first); - // two sessions share a title: with no answer to tell, the chat shows none, also when the pane - // was bound to one of the two; an answer of one of them tells - expect(await bind(null, "Simple Ok Reply", [first, fresh, twin])).toBeNull(); - expect(await bind(fresh, "Simple Ok Reply", [first, fresh, twin])).toBe(fresh); - expect(await bind(null, "Simple Ok Reply", [first, fresh, twin])).toBeNull(); - expect(await bind(twin, "Simple Ok Reply", [first, fresh, twin])).toBe(twin); - // a status line too narrow for the title, or covered by a menu, changes nothing - expect(await bind(first, "Binding Check")).toBe(first); - expect(await bind(null, undefined)).toBe(first); - // another title, while a long history is still being read for its own: the pane was bound - // under "Binding Check", so it is let go without reading anything - expect(await bind(null, "New Session", [first, fresh, big])).toBeNull(); - // the same session matched again while a menu hides the status line keeps the title it was bound - // under: another title then still lets it go, its own history unread or not - expect(await bind(first, "Binding Check", [first, fresh, big])).toBe(first); - expect(await bind(first, undefined, [first, fresh, big])).toBe(first); - expect(await bind(null, "New Session", [first, fresh, big])).toBeNull(); - // bound to a parent, then /resume into its fork, which shares title and answer and is not read - // yet: the answer on screen is not the parent's alone, so the parent is let go - expect(await bind(first, "Binding Check", [first, fresh, big])).toBe(first); - expect(await boundGjcTranscript("w1:p1", gjc, async () => ({ path: null, shared: true, title: "Binding Check", titled: null, titledCount: -1, titles: new Map([[first, "Binding Check"], [big, undefined]]) }))).toBeNull(); - // a narrow pane cuts the title: every session is asked then, and an answer two differently - // titled sessions both hold is no switch. The binding stays, also once the answer scrolls away - expect(await bind(first, "Binding Check", [first, fresh])).toBe(first); - const everyone = gjcTitles([first, fresh], undefined, (path) => titles[path]); - expect(everyone.among).toEqual([first, fresh]); - expect(await boundGjcTranscript("w1:p1", gjc, async () => ({ path: null, shared: true, title: undefined, titled: null, titledCount: 0, titles: everyone.titles }))).toBe(first); - expect(await bind(null, undefined)).toBe(first); - // a session bound before gjc titled it stays when the title appears, written to its file or not yet - expect(await bind(untitled, null, [first, untitled])).toBe(untitled); - expect(await bind(null, "Not Written Yet", [first, untitled])).toBe(untitled); - titles[untitled] = "Not Written Yet"; - expect(await bind(null, "Not Written Yet", [first, untitled])).toBe(untitled); - expect(await bind(null, "Binding Check", [untitled])).toBeNull(); - forgetTranscriptState(); +it("shares one SDK lookup between overlapping reads of a pane", async () => { + const home = tempDir("gjc-sdk-single-flight-"); + try { + const cwd = join(home, "work"), agentDir = join(home, ".gjc", "agent"); + const project = join(agentDir, "sessions", "v2-project"), path = join(project, "main.jsonl"); + mkdirSync(cwd); mkdirSync(project, { recursive: true }); + const sessionId = "sdk-session-flight"; + writeFileSync(path, JSON.stringify({ type: "session", id: sessionId, cwd }) + "\n"); + const binding = { sessionId, pid: 200, path, hostIncarnation: "host", endpointGeneration: 1 }; + let lookups = 0; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { release = resolve; }); + let gated = true; + const options = { + platform: "linux", + processInfo: async () => ({ process_info: { foreground_processes: [{ pid: 200, argv: ["/usr/bin/gjc"] }] } }), + processBirth: () => 1000, + sdkTranscript: async () => { lookups += 1; if (gated) await gate; return lookups > 6 ? null : binding; }, + }; + // regression (fails on main: 5 lookups): five overlapping reads of one pane share one lookup + const burst = Array.from({ length: 5 }, () => gjcTranscriptForPane("pane", cwd, home, options)); + release(); + expect(await Promise.all(burst)).toEqual(Array(5).fill(realpathSync(path))); + expect(lookups).toBe(1); + // control: once it settles, the next read looks up again (nothing is retained) + gated = false; + expect(await gjcTranscriptForPane("pane", cwd, home, options)).toBe(realpathSync(path)); + expect(lookups).toBe(2); + expect(await gjcTranscriptForPane("pane", cwd, home, options)).toBe(realpathSync(path)); + expect(lookups).toBe(3); + // control: another pane is not joined + gated = true; + const [a, b] = [gjcTranscriptForPane("pane-a", cwd, home, options), gjcTranscriptForPane("pane-b", cwd, home, options)]; + release(); + await Promise.all([a, b]); + expect(lookups).toBe(5); + // control: a null answer is not kept, the next read asks again + gated = false; lookups = 6; + expect(await gjcTranscriptForPane("pane", cwd, home, options)).toBeNull(); + expect(lookups).toBe(7); + lookups = 0; + expect(await gjcTranscriptForPane("pane", cwd, home, options)).toBe(realpathSync(path)); + expect(lookups).toBe(1); + } finally { rmSync(home, { recursive: true, force: true }); } }); -it("tells a reused process number by the start time, once it is known", async () => { - forgetTranscriptState(); - const file = "C:\\s\\one.jsonl"; - type Process = { pid: number; started: number | null }; - const bind = (process: Process | undefined, path: string | null) => boundGjcTranscript("w1:p1", process, async () => ({ path, title: undefined, titled: null, titledCount: 0, titles: new Map() })); - // bound while the start time could not be read, learned later, then another process takes the number - expect(await bind({ pid: 200, started: null }, file)).toBe(file); - expect(await bind({ pid: 200, started: 1000 }, null)).toBe(file); - expect(await bind({ pid: 200, started: 2000 }, null)).toBeNull(); - // a read without the start time does not erase the one known, with an answer on screen or none - expect(await bind({ pid: 200, started: 1000 }, file)).toBe(file); - expect(await bind({ pid: 200, started: null }, file)).toBe(file); - expect(await bind({ pid: 200, started: null }, null)).toBe(file); - expect(await bind({ pid: 200, started: 2000 }, null)).toBeNull(); - // a process table that could not be read changes nothing - expect(await bind({ pid: 200, started: 1000 }, file)).toBe(file); - expect(await bind(undefined, null)).toBe(file); +it.each(["cwd", "home", "SDK callback"])("keeps overlapping pane reads separate when %s differs", async (context) => { forgetTranscriptState(); + const home = tempDir("gjc-sdk-flight-context-"); + const entered = Promise.withResolvers(), gate = Promise.withResolvers(); + try { + // Given two valid sessions for the same live pane/process. + const cwdA = join(home, "a"), cwdB = context === "cwd" ? join(home, "b") : cwdA; + const homeB = context === "home" ? join(home, "other-home") : home; + const projectA = join(home, ".gjc/agent/sessions/project"); + const projectB = join(homeB, ".gjc/agent/sessions/project"); + mkdirSync(cwdA); mkdirSync(cwdB, { recursive: true }); + mkdirSync(projectA, { recursive: true }); mkdirSync(projectB, { recursive: true }); + const pathA = join(projectA, "a.jsonl"), pathB = join(projectB, "b.jsonl"); + writeFileSync(pathA, JSON.stringify({ type: "session", id: "A", cwd: cwdA }) + "\n"); + writeFileSync(pathB, JSON.stringify({ type: "session", id: "B", cwd: cwdB }) + "\n"); + const bindingA = { sessionId: "A", pid: 200, path: pathA, hostIncarnation: "host", endpointGeneration: 1 }; + const bindingB = { ...bindingA, sessionId: "B", path: pathB }; + let lookups = 0; + const options = { + platform: "linux", + processInfo: async () => ({ process_info: { foreground_processes: [{ pid: 200, argv: ["gjc"] }] } }), + processBirth: () => 1000, + sdkTranscript: async (_pids: readonly number[], cwd: string, agentDir: string) => { + lookups += 1; + if (cwd !== cwdA || agentDir !== join(home, ".gjc/agent")) return bindingB; + entered.resolve(); + await gate.promise; + return bindingA; + }, + }; + // When a later snapshot arrives after the first SDK lookup has actually started. + const first = gjcTranscriptForPane("pane", cwdA, home, options); + await entered.promise; + const equivalent = gjcTranscriptForPane("pane", cwdA, home, { ...options }); + const laterOptions = context === "SDK callback" + ? { ...options, sdkTranscript: async () => { lookups += 1; return bindingB; } } + : options; + const later = gjcTranscriptForPane("pane", cwdB, homeB, laterOptions); + gate.resolve(); + // Then only equivalent contexts join, and each directory/store receives its own session. + expect(equivalent).toBe(first); + expect(await Promise.all([first, equivalent, later])).toEqual([pathA, pathA, pathB]); + expect(lookups).toBe(2); + } finally { + gate.resolve(); + forgetTranscriptState(); + rmSync(home, { recursive: true, force: true }); + } +}, 5000); + +it("fails closed when the pane has no GJC process or its process info cannot be read", async () => { + const home = tempDir("gjc-sdk-no-process-"); + try { + const cwd = join(home, "work"); + mkdirSync(cwd); + let sdkCalls = 0; + const options = { + platform: "linux", + processInfo: async () => ({ process_info: { foreground_processes: [] } }), + sdkTranscript: async () => { sdkCalls += 1; return null; }, + }; + expect(await gjcTranscriptForPane("pane", cwd, home, options)).toBeNull(); + expect(sdkCalls).toBe(0); + expect(await gjcTranscriptForPane("pane", cwd, home, { + ...options, + processInfo: async () => null, + })).toBeNull(); + expect(sdkCalls).toBe(0); + } finally { rmSync(home, { recursive: true, force: true }); } }); -it("reads a session file's title from its header and gjc's later patches, a bounded part at a time", () => { +it("uses a fresh Windows process tree for the final process identity check", async () => { forgetTranscriptState(); - const dir = mkdtempSync(join(tmpdir(), "herdr-gjc-title-")); + const home = tempDir("gjc-sdk-windows-race-"); try { - const path = join(dir, "s.jsonl"); - const header = (title?: string, cwd = "C:\\x") => JSON.stringify({ type: "session", id: "a", cwd, ...(title ? { title } : {}) }) + "\n"; - const patch = (title: string) => JSON.stringify({ type: "header_patch", patch: { title, titleSource: "auto" } }) + "\n"; - writeFileSync(path, header()); - expect(gjcSessionTitle(path)).toBeNull(); - appendFileSync(path, patch("Binding Check")); - expect(gjcSessionTitle(path)).toBe("Binding Check"); - // a "title" inside a message is not gjc's - appendFileSync(path, JSON.stringify({ type: "message", message: { role: "user", content: [{ type: "text", text: '"title": no' }] } }) + "\n"); - expect(gjcSessionTitle(path)).toBe("Binding Check"); - // a file of the same size changed in place is read anew, also where its first bytes are the same - const long = "C:\\" + "d".repeat(300); - for (const [before, after] of [[header("Old Name"), header("New Name")], [header("Old Name", long), header("New Name", long)], [header() + patch("Old Name"), header() + patch("New Name")]] as const) { - writeFileSync(path, before); - utimesSync(path, 1000, 1000); - expect(gjcSessionTitle(path)).toBe("Old Name"); - writeFileSync(path, after); - utimesSync(path, 2000, 2000); - expect(gjcSessionTitle(path)).toBe("New Name"); - } - // a long history is read within the budget: unknown until its end is reached, over several looks - const history = join(dir, "long.jsonl"); - writeFileSync(history, header() + (JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: "x".repeat(1000) }] } }) + "\n").repeat(3000) + patch("Late Title")); - expect(gjcSessionTitle(history, { bytes: 1024 * 1024 })).toBeUndefined(); - expect(gjcSessionTitle(history, { bytes: 1024 * 1024 })).toBeUndefined(); - expect(gjcSessionTitle(history, { bytes: 4 * 1024 * 1024 })).toBe("Late Title"); - // a file that shrank is read anew, also one whose scan was not finished and still fits - const shrunk = join(dir, "shrunk.jsonl"); - const filler = (JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: "z".repeat(1000) }] } }) + "\n"); - writeFileSync(shrunk, header() + patch("Old Name") + filler.repeat(3000)); - expect(gjcSessionTitle(shrunk, { bytes: 1024 * 1024 })).toBeUndefined(); - writeFileSync(shrunk, header() + patch("New Name") + filler.repeat(2000)); - expect(gjcSessionTitle(shrunk, { bytes: 8 * 1024 * 1024 })).toBe("New Name"); - // more files than one pane's folder holds do not push a scan under way out - for (let n = 0; n < 700; n++) { const other = join(dir, `o${n}.jsonl`); writeFileSync(other, header(`T${n}`)); gjcSessionTitle(other); } - appendFileSync(history, patch("Later Still")); - expect(gjcSessionTitle(history, { bytes: 300 })).toBe("Later Still"); - // a record longer than a chunk (a picture) is passed over - const picture = join(dir, "picture.jsonl"); - writeFileSync(picture, header() + JSON.stringify({ type: "message", message: { role: "toolResult", content: [{ type: "text", text: "y".repeat(700 * 1024) }] } }) + "\n" + patch("After Picture")); - expect(gjcSessionTitle(picture)).toBe("After Picture"); - } finally { rmSync(dir, { recursive: true, force: true }); forgetTranscriptState(); } -}, 20_000); + const cwd = join(home, "work"), agentDir = join(home, ".gjc", "agent"); + const project = join(agentDir, "sessions", "v2-project"), path = join(project, "main.jsonl"); + mkdirSync(cwd); mkdirSync(project, { recursive: true }); + const sessionId = "sdk-session-windows"; + writeFileSync(path, JSON.stringify({ type: "session", id: sessionId, cwd }) + "\n"); + const binding = { sessionId, pid: 200, path, hostIncarnation: "host", endpointGeneration: 1 }; + const gjc = "C:\\Users\\u\\AppData\\Local\\gjc\\gjc.exe"; + const rows = (started: number) => [{ pid: 200, parent: 100, path: gjc, commandLine: `"${gjc}" --resume`, started }]; + let tableReads = 0; + const readProcessTable = async () => ++tableReads === 1 ? rows(1000) : rows(2000); + const processInfo = async () => ({ process_info: { shell_pid: 100, foreground_processes: [] } }); + const result = await gjcTranscriptForPane("pane", cwd, home, { + platform: "win32", + processInfo, + readProcessTable, + sdkTranscript: async (pids: readonly number[]) => { expect(pids).toEqual([200]); return binding; }, + }); + expect(tableReads).toBe(2); + expect(result).toBeNull(); + } finally { rmSync(home, { recursive: true, force: true }); forgetTranscriptState(); } +}); it("asks the Windows process table once for the polls of a few seconds", async () => { forgetTranscriptState(); @@ -347,12 +362,10 @@ it("asks the Windows process table once for the polls of a few seconds", async ( let asked = 0; const read = async () => { asked += 1; return rows; }; expect(await recentProcessTable(read, 10_000)).toBe(rows); - // two polls arriving together share one query await Promise.all([recentProcessTable(read, 12_000), recentProcessTable(read, 14_000)]); expect(asked).toBe(1); await recentProcessTable(read, 16_000); expect(asked).toBe(2); - // a table that could not be read is not kept forgetTranscriptState(); let failed = 0; const unreadable = async () => { failed += 1; return []; }; @@ -360,42 +373,3 @@ it("asks the Windows process table once for the polls of a few seconds", async ( await recentProcessTable(unreadable, 20_001); expect(failed).toBe(2); }); - -it("keeps every whole record of a candidate's tail window", () => { - const root = tempDir("gjc-candidates-"); - try { - mkdirSync(join(root, "project")); - const path = join(root, "project", "session.jsonl"); - // every line is `line` bytes plus its newline: 1024 divides 64 KiB, so that window starts on a record - for (const [line, aligned] of [[1000, false], [1023, true]] as const) { - const record = (i: number) => { - const text = JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: `answer ${String(i).padStart(3, "0")} ` }] } }); - return text.replace(" \"}]", ` ${"x".repeat(line - text.length)}"}]`); - }; - const full = [JSON.stringify({ type: "session", cwd: "/work" }), ...Array.from({ length: 100 }, (_, i) => record(i))].join("\n") + "\n"; - writeFileSync(path, full); - const start = full.length - 65536; - expect(full[start - 1] === "\n").toBe(aligned); - const [candidate] = gjcDisplayCandidates(root, "/work"); - // only a record the window cuts is dropped; the first whole one stays - expect(candidate?.text).toBe(full.slice(aligned ? start : full.indexOf("\n", start) + 1)); - } - } finally { rmSync(root, { recursive: true, force: true }); } -}); - -it("matches only substantial assistant text and rejects shared or short text", () => { - const answer = "A unique assistant response with enough concrete details to identify this conversation across terminal line wrapping and punctuation changes."; - const file = (path: string, role: string, text: string) => ({ path, text: JSON.stringify({ type: "message", message: { role, content: [{ type: "text", text }] } }) }); - expect(matchGjcTranscript(answer.replaceAll(" ", "\n"), [file("a", "assistant", answer)])).toBe("a"); - // a fork holds its parent's answer; while its own title is still unread it is not left out of - // the match (the parent would pass for the only one), and it is not chosen either - const parent = file("parent", "assistant", answer), fork = file("fork", "assistant", answer); - expect(gjcAnswerAmong(answer, [parent, fork], ["parent"], ["fork"])).toEqual({ path: null, shared: true }); - expect(gjcAnswerAmong(answer, [parent, fork], ["parent"], [])).toEqual({ path: "parent", shared: false }); - expect(gjcAnswerAmong(answer, [fork], [], ["fork"])).toEqual({ path: null, shared: true }); - expect(gjcAnswerAmong(answer, [parent, fork], ["parent", "fork"], [])).toEqual({ path: null, shared: true }); - expect(gjcAnswerAmong("nothing of theirs", [parent, fork], ["parent"], ["fork"])).toEqual({ path: null, shared: false }); - expect(matchGjcTranscript(answer, [file("a", "assistant", answer), file("b", "assistant", answer)])).toBeNull(); - expect(matchGjcTranscript(answer, [file("a", "user", answer)])).toBeNull(); - expect(matchGjcTranscript("Done", [file("a", "assistant", "Done")])).toBeNull(); -}); diff --git a/server/gjc-runtime.ts b/server/gjc-runtime.ts index 92fb1d052..7c3437a50 100644 --- a/server/gjc-runtime.ts +++ b/server/gjc-runtime.ts @@ -1,17 +1,15 @@ /** - * gjc sessions: the process identity GJC keys its terminal breadcrumb by, and the - * resolver from a pane to the one transcript that process writes. It answers null - * rather than guessing; conversation.ts turns that into the chat lens's fallback. + * GJC conversation identity comes only from its public SDK broker binding a live pane + * process to its exact saved session. Shared terminal/store helpers below are also used by omp. */ import { execFileSync } from "node:child_process"; import { closeSync, fstatSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, statSync } from "node:fs"; import nodePath, { join, type PlatformPath } from "node:path"; -import { readRange } from "./codex.ts"; -import { herdrRpc, paneRead } from "./herdr/client.ts"; +import { gjcSdkTranscript } from "./gjc-sdk.ts"; +import { herdrRpc } from "./herdr/client.ts"; import { processStartedAt } from "./process-start.ts"; -import { parseOmpTranscript } from "./transcript-records.ts"; import { descendantArgv, windowsProcessTable, type ProcessRow } from "./windows-processes.ts"; export interface GjcTerminal { id: string; startedAt: number } @@ -81,221 +79,8 @@ export async function gjcPidUnderShell( return null; } -/** `title`: what gjc's status line showed when the pane was bound (null: none yet) */ -const windowsBindings = new Map(); - -/** - * What a pane's screen says: the session an answer on it matches, the title gjc's status line - * shows (null: a session with no title yet, as one is right after /new; undefined: no status - * line, or one too narrow to hold the title whole), the title of every session file of the - * folder (undefined: not read to its end yet), how many carry the screen's title (-1 while one - * is still unread) and, when it is one, that file. - */ -export type GjcScreen = { - path: string | null; title: string | null | undefined; titled: string | null; titledCount: number; titles: ReadonlyMap; - /** an answer on screen is of more than one session that may be running, or of one not read yet: it names none, and the one bound may not be it */ - shared?: boolean; -}; - -/** - * The title in gjc's status line (gjc 0.16.4 on a Windows PC): - * `⬢ sonnet-5 · ◒ med · 1.8% / 📁 ~\\dir ──── Simple Ok Reply / ⤴ 0.3/s / $0.04 (sub) / v0.16.4`, - * and `──── (sub) / v0.16.4` for a session with no title yet. Only the line right over the - * message box at the bottom of the screen counts: the same lines printed in an answer are text, - * and while a menu stands where the box does (/resume) there is no status to read. Read from - * the right, since a title may hold ` / ` and parentheses itself: the version, gjc's cost and - * plan, its speed, each once, and what is left is the title. - */ -export function gjcStatusTitle(screen: string): string | null | undefined { - const lines = screen.split(/\r?\n/); - let at = lines.length - 1; - while (at >= 0 && lines[at]!.trim() === "") at -= 1; - if (at < 0 || !/^\s*\u2570\u2500/.test(lines[at]!)) return undefined; - at -= 1; - while (at >= 0 && /^\s*\u2502/.test(lines[at]!)) at -= 1; - if (at < 1 || !/^\s*\u256d\u2500/.test(lines[at]!)) return undefined; - const status = lines[at - 1]!; - if (!/^\s*\u2b22\s/.test(status) || !status.includes("\u{1F4C1}")) return undefined; - const version = status.match(/\s\/\s+v\d+\.\d+\.\d+\s*$/); - // one rule between the folder and the title: with two, the folder or the title holds one and neither can be told - const rules = [...status.matchAll(/\u2500{3,}\s/g)]; - const rule = rules[0]; - if (!version || rules.length !== 1 || !rule || rule.index + rule[0].length > version.index!) return undefined; - const parts = status.slice(rule.index + rule[0].length, version.index).split(" / ").map((part) => part.trim()); - // `$0.04 (sub)`, or `(sub)` alone before anything was spent; then `⤴ 0.3/s` - if (/^(?:\$[\d.]+\s+)?\(sub\)$|^\$[\d.]+$/.test(parts.at(-1) ?? "")) parts.pop(); - if (/^\u2934\s*[\d.]+\/s$/u.test(parts.at(-1) ?? "")) parts.pop(); - const title = parts.join(" / ").trim(); - if (title === "") return null; - return title.endsWith("\u2026") ? undefined : title; -} - -const TITLE_CHUNK_BYTES = 256 * 1024; -/** bytes of session files one look at a pane may read for titles; what is left is read by the next looks */ -export const TITLE_BUDGET_BYTES = 2 * 1024 * 1024; -interface TitleScan { file: string; size: number; modified: number; head: string; scanned: number; title: string | null; skipping: boolean } -/** kept per file while panes look at it: 64 candidates a folder, and more panes than a few */ -const titleScans = new Map(); - -/** - * A session file's title: its header's, or the last one gjc patched in later (`header_patch`). - * undefined while the file is not read to its end: a file is scanned in chunks, within `budget`, - * and the scan goes on from where it stopped at the next call, as files only grow. Another file - * in its place, one that shrank, or one changed without growing is read anew. - */ -export function gjcSessionTitle(path: string, budget: { bytes: number } = { bytes: TITLE_BUDGET_BYTES }): string | null | undefined { - let fd: number; - try { fd = openSync(path, "r"); } catch { return null; } - try { - const stat = fstatSync(fd); - const size = stat.size; - const file = `${stat.dev}:${stat.ino}`; - const first = Buffer.alloc(Math.min(256, size)); - readSync(fd, first, 0, first.length, 0); - const head = first.toString("latin1"); - let scan = titleScans.get(path); - if (!scan || scan.file !== file || size < scan.size || (size === scan.size && stat.mtimeMs !== scan.modified) - || !head.startsWith(scan.head.slice(0, head.length)) || head.length < scan.head.length) { - scan = { file, size, modified: stat.mtimeMs, head, scanned: 0, title: null, skipping: false }; - } - scan.head = head; scan.size = size; scan.modified = stat.mtimeMs; - while (scan.scanned < size && budget.bytes > 0) { - const chunk = Buffer.alloc(Math.min(TITLE_CHUNK_BYTES, size - scan.scanned, budget.bytes)); - const got = readSync(fd, chunk, 0, chunk.length, scan.scanned); - if (got <= 0) break; - budget.bytes -= got; - const bytes = chunk.subarray(0, got); - const lastBreak = bytes.lastIndexOf(0x0a); - if (lastBreak === -1) { - // a record longer than a chunk (a picture) is no title record: pass over it - if (got < TITLE_CHUNK_BYTES) break; - scan.skipping = true; - scan.scanned += got; - continue; - } - let from = 0; - if (scan.skipping) { from = bytes.indexOf(0x0a) + 1; scan.skipping = false; } - for (const line of bytes.subarray(from, lastBreak).toString("utf8").split("\n")) { - if (!line.includes('"title"')) continue; - try { - const record = JSON.parse(line) as { type?: unknown; title?: unknown; patch?: { title?: unknown } }; - const title = record.type === "session" ? record.title : record.type === "header_patch" ? record.patch?.title : undefined; - if (typeof title === "string" && title.trim()) scan.title = title.trim(); - } catch { /* not a record */ } - } - scan.scanned += lastBreak + 1; - } - titleScans.delete(path); - titleScans.set(path, scan); - if (titleScans.size > 8192) titleScans.delete(titleScans.keys().next().value!); - // a last line still being written is not waited for: it is read once it has its newline - return size - scan.scanned > TITLE_CHUNK_BYTES || (scan.scanned < size && budget.bytes <= 0) ? undefined : scan.title; - } catch { return null; } - finally { closeSync(fd); } -} - -/** - * What one look at a pane reads from its folder's session files: each file's title within the - * budget, and the files an answer on screen may be matched among. An answer counts only for a - * session that can be the one running: one carrying the status line's title, or, while no file - * carries it yet, one without a title. - */ -export function gjcTitles( - paths: readonly string[], title: string | null | undefined, - titleOf: (path: string, budget: { bytes: number }) => string | null | undefined = gjcSessionTitle, - sizeOf: (path: string) => number = fileSize, -): Pick & { among: string[]; unread: string[] } { - const titles = new Map(); - if (typeof title !== "string" && title !== null) return { titled: null, titledCount: 0, titles, among: [...paths], unread: [] }; - const budget = { bytes: TITLE_BUDGET_BYTES }; - // the small ones first: one long history must not use up the look before a short session is read - const sizes = new Map(paths.map((path) => [path, sizeOf(path)])); - for (const path of [...paths].sort((a, b) => sizes.get(a)! - sizes.get(b)!)) titles.set(path, titleOf(path, budget)); - // a file not read to its end may carry any title: it is no untitled session to match an answer in - const untitled = paths.filter((path) => titles.get(path) === null); - // and it cannot be chosen by an answer either, yet an answer it shares is not one session's alone - const unread = paths.filter((path) => titles.get(path) === undefined); - if (title === null) return { titled: null, titledCount: 0, titles, among: untitled, unread }; - const carrying = paths.filter((path) => titles.get(path) === title); - return { - titled: carrying.length === 1 && unread.length === 0 ? carrying[0]! : null, - // two that carry it are two, whatever the unread ones turn out to carry - titledCount: carrying.length > 1 ? carrying.length : unread.length > 0 ? -1 : carrying.length, - titles, - among: carrying.length > 0 ? carrying : untitled, - unread, - }; -} - -function fileSize(path: string): number { - try { return statSync(path).size; } catch { return 0; } -} - -/** The start time decides only when both reads have it: a column that failed once is not another process. */ -const sameProcess = (a: GjcProcess, b: GjcProcess) => a.pid === b.pid && (a.started === null || b.started === null || a.started === b.started); - -/** - * A Windows pane's session. The screen is the only evidence there, and it is gone whenever no - * recent answer's tail is visible (a long list, tool output; live-verified: the lens fell back - * for the whole of such an answer). So what the screen once showed is kept for the pane while - * the same gjc process runs in it, and a pane is never bound without the screen. - * - * gjc's status line names the session the process runs now, and the session file carries the - * same title. After /new or /resume the same process runs another session, and a one-word answer - * never matched (measured on a real PC: the chat stayed on the old conversation). So the title - * decides when it can: one file with it is the session; several leave only an answer of one of - * them to tell, and without one the chat shows none rather than the last session; a title other - * than the one the pane was bound under, or none where the bound session has one, ends the - * binding. A process table that could not be read (`undefined`) leaves the binding as it was. - */ -export async function boundGjcTranscript(paneId: string, gjc: GjcProcess | null | undefined, look: () => Promise): Promise { - let bound = windowsBindings.get(paneId); - if (gjc === null || gjc && bound && !sameProcess(bound.process, gjc)) { - windowsBindings.delete(paneId); - bound = undefined; - } - if (gjc === null) return null; - // a start time once known is kept: a later read without it must not hide a reused number - if (bound && gjc && gjc.started !== null) bound.process = gjc; - const screen = await look(); - const process = gjc && bound && gjc.started === null ? bound.process : gjc ?? bound?.process; - const title = screen?.title; - const known = (path: string) => screen?.titles.get(path); - const found = typeof title === "string" && screen!.titledCount === 1 ? screen!.titled : screen?.path ?? null; - if (found && process) { - // matched again with no status line to read: what the pane was bound under still stands - windowsBindings.set(paneId, { process, path: found, title: title === undefined && bound?.path === found ? bound.title : title }); - return found; - } - if (!bound) return null; - const own = typeof bound.title === "string" ? bound.title : known(bound.path); - // an answer on screen that several sessions hold, the one bound perhaps among them, tells it is - // not known which runs. Not while the status line cannot be read (a narrow pane): every session - // of the folder is then asked, and two that say the same thing are no sign of a switch - const other = (screen?.shared === true && title !== undefined) || (title === null ? typeof own === "string" - : typeof title === "string" && (screen!.titledCount > 1 || typeof own === "string" && own !== title)); - if (other) { - windowsBindings.delete(paneId); - return null; - } - // gjc titled the session after the pane was bound to it - if (typeof title === "string" && known(bound.path) === title) bound.title = title; - return bound.path; -} - -/** Forget the bindings and the process table kept between polls. */ -export function forgetGjcState(): void { - windowsBindings.clear(); - titleScans.clear(); - processTable = null; -} - -/** What one pane's gjc chat left behind: its bound session and that file's title scan (a memo either way). */ -export function forgetGjcPane(paneId: string): void { - const bound = windowsBindings.get(paneId); - windowsBindings.delete(paneId); - if (bound !== undefined) titleScans.delete(bound.path); -} +/** Forget the process table kept between Windows polls. */ +export function forgetGjcState(): void { processTable = null; transcriptFlights.clear(); } /** * Where `file` sits inside `root`, as path segments, or null when it is not inside. The @@ -371,67 +156,9 @@ function transcriptCwd(path: string): string | null { } } -/** Unique visible transcript evidence; timestamps never choose a winner. */ -export function matchGjcTranscript(screen: string, candidates: { path: string; text: string }[]): string | null { - const normalize = (value: string) => value.normalize("NFKC").replace(/[^\p{L}\p{N}]/gu, ""); - const visible = normalize(screen); - const matches = new Set(); - for (const file of candidates) { - const turns = parseOmpTranscript(file.text, Infinity).filter(turn => turn.role === "assistant").slice(-8); - if (turns.some(turn => turn.parts.some(part => { - if (part.kind !== "text") return false; - const anchor = normalize(part.text).slice(-160); - return anchor.length >= 64 && visible.includes(anchor); - }))) matches.add(file.path); - } - return matches.size === 1 ? [...matches][0]! : null; -} - -/** - * The one session among `among` an answer on screen is of. A file whose title is not read yet - * is matched too, so that an answer it shares with another (a fork holds its parent's) is - * nobody's; alone it is not chosen. - */ -export function gjcAnswerAmong(screen: string, files: { path: string; text: string }[], among: readonly string[], unread: readonly string[]): { path: string | null; shared: boolean } { - const hits = files.filter((file) => (among.includes(file.path) || unread.includes(file.path)) && matchGjcTranscript(screen, [file]) === file.path).map((file) => file.path); - const only = hits.length === 1 && among.includes(hits[0]!) ? hits[0]! : null; - return { path: only, shared: hits.length > 0 && only === null }; -} - -/** Bound both directory enumeration and content reads; never match an arbitrary subset. */ -export function gjcDisplayCandidates(root: string, cwd: string): { path: string; text: string }[] { - try { - const dirs = readdirSync(root, { withFileTypes: true }); - if (dirs.length > 512) return []; - const paths = new Set(); - let inspected = 0; - for (const dir of dirs) { - if (!dir.isDirectory()) continue; - const entries = readdirSync(join(root, dir.name)); - inspected += entries.length; - if (inspected > 4096) return []; - for (const name of entries) { - if (!name.endsWith(".jsonl")) continue; - const path = realpathSync(join(root, dir.name, name)); - if (storeRelative(root, path) && statSync(path).isFile() && transcriptCwd(path) === cwd) paths.add(path); - } - } - if (paths.size > 64) return []; - return [...paths].map(path => { - const size = statSync(path).size; - // readRange drops through the first newline; starting one byte early keeps a record - // the 64 KiB window starts exactly on, and still drops one it cuts - return { path, text: readRange(path, Math.max(0, size - 65536 - 1), size) }; - }); - } catch { return []; } -} - /** - * The session a transcript belongs to. GJC keeps a session's subagents beside it, as - * `//.jsonl` next to `/.jsonl`, and they run inside the - * session's own process. That process points the terminal breadcrumb at a subagent's file while it - * runs, and leaves it there; a subagent's file can be the one it holds open. Either way the pane - * shows the session, so a subagent's file stands for its session's, and anything else is refused. + * Shared breadcrumb/descriptor lookup: GJC and omp subagents live beside their session file, so + * this helper maps such a path to its parent. The SDK-based GJC pane resolver does not use it. */ export function gjcSessionFile(root: string, path: string, paths: PlatformPath = nodePath): string | null { const parts = path.endsWith(".jsonl") ? storeRelative(root, path, paths) : null; @@ -442,10 +169,7 @@ export function gjcSessionFile(root: string, path: string, paths: PlatformPath = try { return statSync(session).isFile() ? session : null; } catch { return null; } } -/** - * Validate the native two-line terminal breadcrumb under `agentDir/terminal-sessions` and reject - * reused-terminal leftovers. GJC and omp both keep one per terminal. - */ +/** Validate the shared native breadcrumb under `agentDir/terminal-sessions` and reject stale leftovers. */ export function terminalBreadcrumb(agentDir: string, cwd: string, terminalId: string, startedAt: number): string | null { if (!/^(?:pts-\d+|tty[\w-]+|tmux-%\d+)$/.test(terminalId) || !Number.isFinite(startedAt)) return null; try { @@ -497,38 +221,151 @@ export function heldTranscript(agentDir: string, cwd: string, pids: readonly num return { path: candidates.size === 1 ? [...candidates][0]! : null, found: candidates.size }; } +const GJC_HEADER_BYTES = 64 * 1024; + +type GjcSdkBinding = NonNullable>>; + +/** The SDK-selected path is usable only as its exact main session transcript. */ +export function validateGjcSdkTranscript(binding: GjcSdkBinding, cwd: string, agentDir: string): string | null { + if (!binding || !Number.isSafeInteger(binding.pid) || binding.pid <= 0 + || typeof binding.sessionId !== "string" || binding.sessionId === "" + || typeof binding.path !== "string" || !nodePath.isAbsolute(binding.path) + || !nodePath.isAbsolute(cwd) || !nodePath.isAbsolute(agentDir)) return null; + let fd: number; + try { + const root = realpathSync(join(agentDir, "sessions")); + const expectedCwd = realpathSync(cwd); + const path = realpathSync(binding.path); + const parts = storeRelative(root, path); + // A GJC main session is sessions//.jsonl. Never translate a subagent path. + if (!parts || parts.length !== 2 || !path.endsWith(".jsonl")) return null; + fd = openSync(path, "r"); + try { + if (!fstatSync(fd).isFile()) return null; + const buffer = Buffer.alloc(GJC_HEADER_BYTES); + const size = readSync(fd, buffer, 0, buffer.length, 0); + const end = buffer.subarray(0, size).indexOf(0x0a); + if (end < 0) return null; + const header = JSON.parse(buffer.subarray(0, end).toString("utf8")) as { type?: unknown; id?: unknown; cwd?: unknown }; + if (header.type !== "session" || header.id !== binding.sessionId || typeof header.cwd !== "string") return null; + const headerCwd = realpathSync(header.cwd); + const sameCwd = process.platform === "win32" + ? headerCwd.toLowerCase() === expectedCwd.toLowerCase() + : headerCwd === expectedCwd; + return sameCwd ? path : null; + } finally { closeSync(fd); } + } catch { return null; } +} + +type GjcPaneProcessInfo = { process_info?: { shell_pid?: unknown; foreground_processes?: { pid?: unknown; argv?: unknown }[] } }; +type GjcProcessIdentity = { pid: number; birth: number }; + +type GjcTranscriptOptions = { + platform?: string; + processInfo?: (paneId: string) => Promise; + processBirth?: (pid: number) => number | null; + sdkTranscript?: typeof gjcSdkTranscript; + readProcessTable?: () => Promise; +}; + +/** Each SDK lookup spawns the gjc CLI, so equivalent pane/directory/I/O contexts share one flight. */ +const transcriptFlights = new Map }>>(); + /** - * gjc's transcript: what its processes hold (heldTranscript), else visible assistant text - * matched against the cwd's sessions. + * Resolves only while the SDK-selected GJC process is still foreground in this pane with the + * same birth identity. Optional I/O callbacks keep this race check fixture-testable. Overlapping + * reads with the same lookup context share a lookup; nothing outlives it, so the next read verifies the + * exact session again (a /new keeps the pid and can keep the old file). */ -export async function gjcTranscriptForPane(paneId: string, cwd: string, home = process.env["HOME"] ?? ""): Promise { - let root: string; - try { root = realpathSync(join(home, ".gjc", "agent", "sessions")); } - catch { return null; } - const info = await herdrRpc<{ process_info?: { shell_pid?: unknown; foreground_processes?: { pid?: unknown; argv?: unknown }[] } }>( - "pane.process_info", - { pane_id: paneId }, - ).catch(() => null); - const pids: number[] = []; - for (const process of info?.process_info?.foreground_processes ?? []) { - const argv = Array.isArray(process.argv) ? process.argv.map(String) : []; - if (typeof process.pid === "number" && isGjcProcess(argv)) pids.push(process.pid); +export function gjcTranscriptForPane( + paneId: string, + cwd: string, + home = process.env["HOME"] ?? "", + options: GjcTranscriptOptions = {}, +): Promise { + const key = JSON.stringify([paneId, cwd, home, options.platform ?? process.platform]); + const flights = transcriptFlights.get(key) ?? new Set<{ options: GjcTranscriptOptions; flight: Promise }>(); + for (const shared of flights) { + if (shared.options.processInfo === options.processInfo + && shared.options.processBirth === options.processBirth + && shared.options.sdkTranscript === options.sdkTranscript + && shared.options.readProcessTable === options.readProcessTable) return shared.flight; } - const running = pids.length > 0; - const held = heldTranscript(join(home, ".gjc", "agent"), cwd, pids); - if (held.path !== null) return held.path; - if (held.found > 1) return null; - // Some GJC builds publish neither a file descriptor nor a terminal breadcrumb. - // Match substantial assistant text in this pane against every same-cwd candidate. - const look = async (): Promise => { - const files = gjcDisplayCandidates(root, cwd); - const screen = await paneRead({ paneId, source: "visible", lines: 1000 }).catch(() => null); - if (!screen) return null; - const title = gjcStatusTitle(screen.text); - const { among, unread, ...titles } = gjcTitles(files.map((file) => file.path), title); - return { ...gjcAnswerAmong(screen.text, files, among, unread), title, ...titles }; + const flight: Promise = lookupGjcTranscript(paneId, cwd, home, options).finally(() => { + flights.delete(entry); + if (transcriptFlights.get(key) === flights && flights.size === 0) transcriptFlights.delete(key); + }); + const entry = { options, flight }; + flights.add(entry); + transcriptFlights.set(key, flights); + return flight; +} + +async function lookupGjcTranscript( + paneId: string, + cwd: string, + home: string, + options: GjcTranscriptOptions, +): Promise { + const platform = options.platform ?? process.platform; + const agentDir = join(home, ".gjc", "agent"); + if (!nodePath.isAbsolute(cwd) || !nodePath.isAbsolute(agentDir)) return null; + const readProcessInfo = options.processInfo ?? ((id: string) => herdrRpc( + "pane.process_info", { pane_id: id }, + ).catch(() => null)); + const sdkTranscript = options.sdkTranscript ?? gjcSdkTranscript; + const readProcessTable = options.readProcessTable ?? windowsProcessTable; + const processBirth = options.processBirth ?? processStartedAt; + // macOS reads a start as now minus whole elapsed seconds, so two reads of one process + // differ by up to a second. + const sameBirth = (a: number, b: number) => platform === "darwin" ? Math.abs(a - b) <= 1000 : a === b; + + const processes = async (info: GjcPaneProcessInfo | null, fresh: boolean): Promise => { + if (!info?.process_info) return null; + if (platform === "win32") { + const table = fresh ? readProcessTable : () => recentProcessTable(readProcessTable); + const process = await gjcPidUnderShell(info.process_info.shell_pid, platform, table); + if (process === undefined) return null; + if (process === null) return []; + if (!Number.isSafeInteger(process.started) || process.started === null) return null; + return [{ pid: process.pid, birth: process.started }]; + } + + const foreground = info.process_info.foreground_processes; + if (!Array.isArray(foreground)) return null; + const found: GjcProcessIdentity[] = []; + const seen = new Map(); + for (const process of foreground) { + const argv = Array.isArray(process.argv) ? process.argv.map(String) : []; + if (!isGjcProcess(argv)) continue; + if (typeof process.pid !== "number" || !Number.isSafeInteger(process.pid) || process.pid <= 0) return null; + const started = processBirth(process.pid); + if (started === null || !Number.isFinite(started)) return null; + const previous = seen.get(process.pid); + if (previous !== undefined && !sameBirth(previous, started)) return null; + if (previous === undefined) { + seen.set(process.pid, started); + found.push({ pid: process.pid, birth: started }); + } + } + return found; }; - if (running) return (await look())?.path ?? null; - // a Windows pane has neither descriptors nor a breadcrumb to read: gjc under its shell, then the screen - return boundGjcTranscript(paneId, await gjcPidUnderShell(info?.process_info?.shell_pid), look); + + try { + const initialInfo = await readProcessInfo(paneId); + if (!initialInfo) return null; + const initial = await processes(initialInfo, false); + if (!initial || initial.length === 0) return null; + const pids = initial.map((process) => process.pid); + const binding = await sdkTranscript(pids, cwd, agentDir); + const owner = binding && initial.find((process) => process.pid === binding.pid); + if (!binding || !owner) return null; + const path = validateGjcSdkTranscript(binding, cwd, agentDir); + if (!path) return null; + + const finalInfo = await readProcessInfo(paneId); + if (!finalInfo) return null; + const final = await processes(finalInfo, true); + return final?.some((process) => process.pid === owner.pid && sameBirth(process.birth, owner.birth)) ? path : null; + } catch { return null; } } diff --git a/server/gjc-sdk-live.contract.test.ts b/server/gjc-sdk-live.contract.test.ts new file mode 100644 index 000000000..ab3194cbf --- /dev/null +++ b/server/gjc-sdk-live.contract.test.ts @@ -0,0 +1,457 @@ +import { expect, it } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { constants } from "node:fs"; +import { accessSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, unlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { ConversationUnavailable, forgetTranscriptState, gjcTranscriptPath, transcriptPage } from "./conversation.ts"; +import { herdrRpc, herdrSocketPath, paneRead, workspaceClose, workspaceCreate } from "./herdr/client.ts"; +import { isGjcProcess } from "./gjc-runtime.ts"; + +const TEST_BIN = process.env["HERDR_GJC_SDK_TEST_BIN"]; +const SUPPORTED_PLATFORM = process.platform === "linux"; +const TEST_TIMEOUT_MS = 90_000; +const PROCESS_STOP_TIMEOUT_MS = 3_000; +const SDK_COMMAND_TIMEOUT_MS = 3_000; + +type JsonRecord = Record; +type PaneProcess = { pid?: unknown; argv?: unknown }; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function sdkArgs(agentDir: string, input?: Record): string[] { + const args = [ + "sdk", "session", "raw", "global", + "--op", "session.list", + "--page", "--limit", "100", + "--agent-dir", agentDir, + "--attach-only", "--json", + ]; + if (input !== undefined) args.push("--json-input", JSON.stringify(input)); + return args; +} + +function sdkResult(agentDir: string, input?: Record): JsonRecord { + const stdout = execFileSync("gjc", sdkArgs(agentDir, input), { + env: { ...process.env, GJC_SDK_BROKER_AUTOSTART: "0" }, + encoding: "utf8", + timeout: SDK_COMMAND_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }); + const envelope: unknown = JSON.parse(stdout); + if (!isRecord(envelope) || envelope.type !== "broker_response" || envelope.ok !== true || !isRecord(envelope.result)) { + throw new Error("GJC SDK returned an invalid broker response"); + } + return envelope.result; +} + +function rowForPid(result: JsonRecord, pid: number, cwd: string): JsonRecord | null { + if (!Array.isArray(result.sessions)) return null; + const rows = result.sessions.filter((row): row is JsonRecord => isRecord(row)); + const matches = rows.filter((row) => { + const locator = row.locator; + return row.pid === pid && row.live === true && row.terminal !== true && row.deleted !== true + && typeof row.endpointGeneration === "number" && row.endpointGeneration > 0 + && isRecord(locator) && locator.cwd === cwd; + }); + return matches.length === 1 ? matches[0]! : null; +} + +async function until( + deadline: number, + read: () => Promise, + accept: (value: T) => boolean, + label: string, +): Promise { + let last: unknown; + while (Date.now() < deadline) { + try { + const value = await read(); + if (accept(value)) return value; + last = value; + } catch (error) { + last = error; + } + await Bun.sleep(100); + } + const detail = last instanceof Error ? `: ${last.message}` + : isRecord(last) && typeof last.text === "string" ? `\nIsolated TUI: ${last.text}` : ""; + throw new Error(`Timed out waiting for ${label}${detail}`); +} + +async function foregroundGjcPid(paneId: string, socketPath: string): Promise { + const response = await herdrRpc<{ process_info?: { foreground_processes?: PaneProcess[] } }>( + "pane.process_info", { pane_id: paneId }, socketPath, 2_000, + ); + const matches = (response.process_info?.foreground_processes ?? []).filter((process) => { + const argv = Array.isArray(process.argv) ? process.argv.map(String) : []; + return typeof process.pid === "number" && isGjcProcess(argv); + }); + return matches.length === 1 ? matches[0]!.pid as number : null; +} + +function transcriptRecords(sessionId: string, cwd: string, title: string, timestamp: string): string { + const userId = randomUUID(); + const assistantId = randomUUID(); + return [ + { type: "session", version: 3, id: sessionId, timestamp, cwd, title }, + { + type: "message", id: userId, parentId: null, timestamp, + message: { role: "user", content: "Answer with one short word: what is the result?" }, + }, + { + type: "message", id: assistantId, parentId: userId, timestamp, + message: { + role: "assistant", content: [{ type: "text", text: "OK" }], + stopReason: "stop", model: "gpt-4.1", provider: "openai", + }, + }, + ].map((record) => JSON.stringify(record)).join("\n") + "\n"; +} + +function hasNativeHeader(path: string, sessionId: string, cwd: string): boolean { + try { + const header: unknown = JSON.parse(readFileSync(path, "utf8").split("\n", 1)[0]!); + return isRecord(header) && header.type === "session" && header.id === sessionId && header.cwd === cwd; + } catch { + return false; + } +} + +function launchLine(binary: string, envPath: string, home: string, configHome: string, stateHome: string, cacheHome: string, resume?: string): string { + const args = [ + shellQuote(binary), + "--no-lsp", "--no-mcp", "--no-title", "--no-pty", + "--model", shellQuote("openai/gpt-4.1"), + "--api-key", shellQuote("test-only-no-model-requests"), + ...(resume === undefined ? [] : ["--resume", shellQuote(resume)]), + ]; + return [ + "/usr/bin/env -i", + `HOME=${shellQuote(home)}`, + `PATH=${shellQuote(envPath)}`, + `TERM=${shellQuote("xterm-256color")}`, + `XDG_CONFIG_HOME=${shellQuote(configHome)}`, + `XDG_STATE_HOME=${shellQuote(stateHome)}`, + `XDG_CACHE_HOME=${shellQuote(cacheHome)}`, + ...args, + ].join(" ") + "\n"; +} + +function ownedProcesses(home: string): number[] { + const marker = `HOME=${home}`; + let entries: string[]; + try { + entries = readdirSync("/proc"); + } catch (error) { + throw new Error(`Cannot inspect Linux process environment for isolated GJC cleanup: ${String(error)}`); + } + const pids: number[] = []; + for (const entry of entries) { + if (!/^\d+$/.test(entry)) continue; + const pid = Number(entry); + if (pid === process.pid) continue; + try { + const environment = readFileSync(`/proc/${entry}/environ`, "utf8"); + if (environment.split("\0").includes(marker)) pids.push(pid); + } catch { + // Exited processes and processes owned by another user are not candidates. + } + } + return pids; +} + +async function stopOwnedProcesses(home: string): Promise { + const signal = (pids: number[], value: NodeJS.Signals) => { + for (const pid of pids) { + try { process.kill(pid, value); } catch { /* exited between enumeration and signal */ } + } + }; + const waitGone = async (deadline: number): Promise => { + let live = ownedProcesses(home); + while (live.length > 0 && Date.now() < deadline) { + await Bun.sleep(100); + live = ownedProcesses(home); + } + return live; + }; + + signal(ownedProcesses(home), "SIGTERM"); + let live = await waitGone(Date.now() + PROCESS_STOP_TIMEOUT_MS); + if (live.length > 0) { + signal(live, "SIGKILL"); + live = await waitGone(Date.now() + PROCESS_STOP_TIMEOUT_MS); + } + if (live.length > 0) throw new Error(`Isolated GJC processes survived cleanup: ${live.join(",")}`); +} + +it.skipIf(!TEST_BIN || !SUPPORTED_PLATFORM)( + "resolves a live GJC session through the public SDK without model requests (opt-in: HERDR_GJC_SDK_TEST_BIN; Linux /proc)", + async () => { + if (!TEST_BIN) throw new Error("HERDR_GJC_SDK_TEST_BIN must be an absolute executable path"); + if (!SUPPORTED_PLATFORM) throw new Error("The live SDK contract test currently requires Linux /proc cleanup"); + + const socketPath = herdrSocketPath(); + const systemPath = process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin"; + const envSnapshot: NodeJS.ProcessEnv = { ...process.env }; + const suppliedBin = TEST_BIN; + let root: string | null = null; + let home = ""; + let workspaceIds: string[] = []; + let environmentWasReplaced = false; + let isolatedChildrenGone = true; + let failed = false; + + try { + expect(isAbsolute(suppliedBin)).toBe(true); + accessSync(suppliedBin, constants.X_OK); + const binary = realpathSync(suppliedBin); + expect(statSync(binary).isFile()).toBe(true); + + root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-web-ui-test-gjc-sdk-live-"))); + home = join(root, "home"); + const cwd = join(root, "project"); + const configHome = join(home, ".config"); + const stateHome = join(home, ".local", "state"); + const cacheHome = join(home, ".cache"); + const agentDir = join(home, ".gjc", "agent"); + const shimDir = join(root, "bin"); + const tmpHome = join(root, "tmp"); + for (const directory of [home, cwd, configHome, stateHome, cacheHome, agentDir, shimDir, tmpHome]) { + mkdirSync(directory, { recursive: true }); + } + symlinkSync(binary, join(shimDir, "gjc")); + const isolatedPath = `${shimDir}:${systemPath}`; + + environmentWasReplaced = true; + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, { + HOME: home, + PATH: isolatedPath, + TERM: "xterm-256color", + LANG: "C.UTF-8", + TMPDIR: tmpHome, + XDG_CONFIG_HOME: configHome, + XDG_STATE_HOME: stateHome, + XDG_CACHE_HOME: cacheHome, + HERDR_SOCKET: socketPath, + }); + // The SDK's pwd scope still requires the canonical Git worktree identity. + execFileSync("git", ["init", "--quiet", cwd], { + env: process.env, stdio: "ignore", timeout: 3_000, + }); + + const startPane = async (label: string, resume?: string): Promise<{ workspaceId: string; paneId: string }> => { + const created = await workspaceCreate({ cwd, label: `herdr-web-ui-test-gjc-sdk-live-${label}` }, socketPath); + workspaceIds.push(created.workspace.workspace_id); + await herdrRpc("pane.send_text", { + pane_id: created.root_pane.pane_id, + text: launchLine(binary, isolatedPath, home, configHome, stateHome, cacheHome, resume), + }, socketPath, 3_000); + return { workspaceId: created.workspace.workspace_id, paneId: created.root_pane.pane_id }; + }; + + const sdkRowForPane = async (paneId: string): Promise<{ pid: number; row: JsonRecord } | null> => { + const pid = await foregroundGjcPid(paneId, socketPath); + if (pid === null) return null; + const result = sdkResult(agentDir); + const row = rowForPid(result, pid, cwd); + return row === null ? null : { pid, row }; + }; + + const firstPane = await startPane("bootstrap"); + const first = await until( + Date.now() + 30_000, + () => sdkRowForPane(firstPane.paneId), + (value) => value !== null, + "the bootstrap GJC SDK row for the foreground pane PID", + ); + if (first === null) throw new Error("Bootstrap SDK row disappeared after its bounded poll"); + const sessionId = first.row.sessionId; + expect(typeof sessionId).toBe("string"); + expect((sessionId as string).length).toBeGreaterThan(0); + expect(first.row.pid).toBe(first.pid); + + await workspaceClose(firstPane.workspaceId, socketPath); + workspaceIds = workspaceIds.filter((id) => id !== firstPane.workspaceId); + await until( + Date.now() + 5_000, + async () => { + try { process.kill(first.pid, 0); return true; } catch { return false; } + }, + (alive) => !alive, + "the bootstrap GJC process to exit after its workspace closes", + ); + + const resumedSessionId = sessionId as string; + expect(resumedSessionId).toMatch(/^[a-zA-Z0-9_-]+$/); + const sessionsRoot = join(agentDir, "sessions"); + const scopeDirs = await until( + Date.now() + 10_000, + async () => { + try { + return readdirSync(sessionsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith("v2-")) + .map((entry) => join(sessionsRoot, entry.name)); + } catch { return []; } + }, + (dirs) => dirs.length === 1, + "the single isolated native GJC v2 session scope", + ); + expect(scopeDirs).toHaveLength(1); + const scopeDir = scopeDirs[0]!; + expect(realpathSync(scopeDir)).toBe(scopeDir); + + const fixtureTimestamp = new Date().toISOString(); + const existingSessionFiles = readdirSync(scopeDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(`_${resumedSessionId}.jsonl`)) + .map((entry) => join(scopeDir, entry.name)); + expect(existingSessionFiles.length).toBeLessThanOrEqual(1); + const existingSessionPath = existingSessionFiles[0]; + const reuseExisting = existingSessionPath !== undefined && hasNativeHeader(existingSessionPath, resumedSessionId, cwd); + expect(existingSessionPath === undefined || reuseExisting).toBe(true); + const fixtureSessionId = resumedSessionId; + const fixtureName = `${fixtureTimestamp.replaceAll(":", "-")}_${fixtureSessionId}.jsonl`; + const fixturePath = reuseExisting ? existingSessionPath : join(scopeDir, fixtureName); + if (!reuseExisting) { + const collision = readdirSync(scopeDir, { withFileTypes: true }) + .some((entry) => entry.isFile() && entry.name.endsWith(`_${fixtureSessionId}.jsonl`)); + expect(collision).toBe(false); + } + const title = "GJC SDK identity fixture"; + writeFileSync(fixturePath, transcriptRecords(fixtureSessionId, cwd, title, fixtureTimestamp)); + + // Same visible title and turns, but newer than the resumed file: screen/mtime guessing cannot + // tell which one the live process owns. Only the SDK's PID-to-session binding can. + const neighborId = randomUUID(); + const neighborPath = join(scopeDir, `${new Date().toISOString().replaceAll(":", "-")}_${neighborId}.jsonl`); + writeFileSync(neighborPath, transcriptRecords(neighborId, cwd, title, fixtureTimestamp)); + const newerTime = new Date(Date.now() + 10 * 60_000); + utimesSync(neighborPath, newerTime, newerTime); + expect(statSync(neighborPath).mtimeMs).toBeGreaterThan(statSync(fixturePath).mtimeMs); + + const resumedPane = await startPane("resume", fixturePath); + const resumed = await until( + Date.now() + 30_000, + () => sdkRowForPane(resumedPane.paneId), + (value) => value !== null, + "the resumed GJC SDK row for the foreground pane PID", + ); + if (resumed === null) throw new Error("Resumed SDK row disappeared after its bounded poll"); + expect(resumed.row.pid).toBe(resumed.pid); + expect(resumed.row.sessionId).toBe(fixtureSessionId); + + const brokerResolution = sdkResult(agentDir, { cwd, resolveSessionId: fixtureSessionId }); + expect(isRecord(brokerResolution.savedSession)).toBe(true); + expect(isRecord(brokerResolution.savedSession) ? brokerResolution.savedSession.id : null).toBe(fixtureSessionId); + expect(isRecord(brokerResolution.savedSession) ? brokerResolution.savedSession.path : null).toBe(fixturePath); + + const resolvedPath = await gjcTranscriptPath(resumedPane.paneId, cwd, home); + expect(resolvedPath).toBe(fixturePath); + const conversation = transcriptPage("gjc-transcript", resolvedPath); + expect(conversation.source).toBe("gjc-transcript"); + const assistant = conversation.turns.find((turn) => turn.role === "assistant"); + expect(assistant?.parts).toContainEqual({ kind: "text", text: "OK" }); + + // A real GJC may hold its transcript open. That must not become an identity fallback + // when the SDK CLI fails: replace only our disposable PATH shim, not the supplied binary. + unlinkSync(join(shimDir, "gjc")); + writeFileSync(join(shimDir, "gjc"), "#!/bin/sh\nexit 1\n", { mode: 0o700 }); + await expect(gjcTranscriptPath(resumedPane.paneId, cwd, home)).rejects.toBeInstanceOf(ConversationUnavailable); + unlinkSync(join(shimDir, "gjc")); + symlinkSync(binary, join(shimDir, "gjc")); + + // A disposable HOME gets a language selector and then onboarding. Escape chooses + // English in the first and cancels the second; neither starts evidence analysis. + const dismissed = new Set(); + await until( + Date.now() + 10_000, + async () => { + const screen = await paneRead({ paneId: resumedPane.paneId, source: "visible", lines: 100 }, socketPath); + const menu = ["Choose your interface language", "Frictionless onboarding"] + .find((title) => screen.text.includes(title)); + if (menu && !dismissed.has(menu)) { + dismissed.add(menu); + await herdrRpc("pane.send_keys", { pane_id: resumedPane.paneId, keys: ["Escape"] }, socketPath); + } + return screen; + }, + (screen) => screen.text.includes("Type your message") && !screen.text.includes("Frictionless onboarding"), + "the isolated GJC editor after dismissing onboarding", + ); + await herdrRpc("pane.send_text", { pane_id: resumedPane.paneId, text: "/new" }, socketPath); + await herdrRpc("pane.send_keys", { pane_id: resumedPane.paneId, keys: ["Enter"] }, socketPath); + const nextSession = await until( + Date.now() + 15_000, + () => sdkRowForPane(resumedPane.paneId), + (value) => value !== null && value.row.sessionId !== fixtureSessionId, + "the real TUI /new command to publish a different SDK session", + ).catch(async (error) => { + const screen = await paneRead({ paneId: resumedPane.paneId, source: "visible", lines: 100 }, socketPath); + throw new Error(`${String(error)}\nIsolated TUI: ${screen.text}\nIsolated SDK: ${JSON.stringify(sdkResult(agentDir))}`); + }); + if (nextSession === null) throw new Error("New SDK session disappeared"); + expect(nextSession.pid).toBe(resumed.pid); + expect(nextSession.row.sessionId).not.toBe(fixtureSessionId); + const nextId = nextSession.row.sessionId; + if (typeof nextId !== "string") throw new Error("New SDK session ID is not a string"); + expect(nextId).toMatch(/^[a-zA-Z0-9_-]+$/); + const nextSaved = sdkResult(agentDir, { cwd, resolveSessionId: nextId }).savedSession; + if (isRecord(nextSaved)) { + if (typeof nextSaved.path !== "string") throw new Error("New SDK transcript path is not a string"); + expect(nextSaved.path).not.toBe(fixturePath); + expect(await gjcTranscriptPath(resumedPane.paneId, cwd, home)).toBe(nextSaved.path); + } else { + await expect(gjcTranscriptPath(resumedPane.paneId, cwd, home)).rejects.toBeInstanceOf(ConversationUnavailable); + // Persist fixture history for the actual new session without making a model request. + // A lingering generation-zero GC fence for the old session must not block this one. + const existing = readdirSync(scopeDir).filter((name) => name.endsWith(`_${nextId}.jsonl`)); + expect(existing.length).toBeLessThanOrEqual(1); + const nextPath = join(scopeDir, existing[0] ?? `${new Date().toISOString().replaceAll(":", "-")}_${nextId}.jsonl`); + writeFileSync(nextPath, transcriptRecords(nextId, cwd, "New session fixture", new Date().toISOString())); + expect(await gjcTranscriptPath(resumedPane.paneId, cwd, home)).toBe(nextPath); + expect(nextPath).not.toBe(fixturePath); + } + + } catch (error) { + failed = true; + throw error; + } finally { + const cleanupErrors: string[] = []; + for (const workspaceId of [...workspaceIds].reverse()) { + try { await workspaceClose(workspaceId, socketPath); } + catch (error) { cleanupErrors.push(`workspace ${workspaceId}: ${String(error)}`); } + } + if (home !== "") { + try { await stopOwnedProcesses(home); } + catch (error) { + isolatedChildrenGone = false; + cleanupErrors.push(String(error)); + } + } + if (environmentWasReplaced) { + for (const key of Object.keys(process.env)) delete process.env[key]; + for (const [key, value] of Object.entries(envSnapshot)) { + if (value !== undefined) process.env[key] = value; + } + } + forgetTranscriptState(); + if (root !== null && isolatedChildrenGone) rmSync(root, { recursive: true, force: true }); + if (cleanupErrors.length > 0) { + const message = `GJC SDK live-test cleanup failed: ${cleanupErrors.join("; ")}`; + // the test's own failure is the one to report; a cleanup failure beside it is only logged + if (failed) console.error(message); + else throw new Error(message); + } + } + }, + TEST_TIMEOUT_MS, +); diff --git a/server/gjc-sdk.test.ts b/server/gjc-sdk.test.ts new file mode 100644 index 000000000..a689e3ab4 --- /dev/null +++ b/server/gjc-sdk.test.ts @@ -0,0 +1,424 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + gjcSdkTranscriptWithRunner as sdkTranscriptWithRunner, + type GjcSdkCommandOptions, + type GjcSdkCommandRunner, + type GjcSdkWorktreeRootResolver, +} from "./gjc-sdk.ts"; + +const cwd = mkdtempSync(join(tmpdir(), "gjc-sdk-test-")); +const agentDir = join(cwd, "agent"); +const savedPath = join(cwd, "sessions", "session.jsonl"); +afterAll(() => rmSync(cwd, { recursive: true, force: true })); + +const locator = () => ({ cwd, worktreeRoot: cwd, stateRoot: join(cwd, ".gjc", "state") }); + +const testWorktreeRootResolver: GjcSdkWorktreeRootResolver = async () => cwd; + +function gjcSdkTranscriptWithRunner( + pids: readonly number[], + workingDirectory: string, + directory: string, + runner: GjcSdkCommandRunner, + rootResolver: GjcSdkWorktreeRootResolver = testWorktreeRootResolver, +) { + return sdkTranscriptWithRunner(pids, workingDirectory, directory, runner, rootResolver); +} + +function session(overrides: Record = {}): Record { + return { + sessionId: "session-1", + locator: locator(), + endpointGeneration: 4, + pid: 321, + hostIncarnation: "linux:12345", + identityProvenance: "composite", + terminalUncertain: false, + ambiguous: false, + terminal: false, + live: true, + ...overrides, + }; +} + +function broker(result: Record): string { + return JSON.stringify({ type: "broker_response", ok: true, result }); +} + +function page(sessions: unknown[], extra: Record = {}): string { + return broker({ indexSeq: 11, sessions, ...extra }); +} + +function scope(cwd: string, worktreeRoot: string | null = cwd) { + return { version: 1, requested: "pwd", requestAnchor: { cwd, worktreeRoot } }; +} + +function saved(row: Record = session(), id = "session-1", path = savedPath): string { + return page([row], { savedSession: { id, path } }); +} + +interface Call { + command: string; + args: string[]; + options: GjcSdkCommandOptions; +} + +function fakeRunner( + output: readonly string[] | ((args: string[], call: number) => string | Promise), +): { calls: Call[]; runner: GjcSdkCommandRunner } { + const calls: Call[] = []; + const runner: GjcSdkCommandRunner = async (command, args, options) => { + const call = calls.length; + calls.push({ command, args: [...args], options }); + if (typeof output === "function") return output(args, call); + const response = output[call]; + if (response === undefined) throw new Error("Unexpected SDK command"); + return response; + }; + return { calls, runner }; +} + +const commandArgs = [ + "sdk", "session", "raw", "global", + "--op", "session.list", + "--page", "--limit", "100", + "--agent-dir", agentDir, + "--attach-only", "--json", +]; + +describe("GJC SDK transcript identity binding", () => { + it("ignores only the proven generation-zero direct-session GC fence after /new", async () => { + mkdirSync(agentDir, { recursive: true }); + const fence = session({ + sessionId: "previous-session", + endpointGeneration: 0, + processIncarnation: "linux:12345", + locator: { ...locator(), stateRoot: agentDir }, + }); + const valid = fakeRunner([page([fence, session()]), saved()]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, valid.runner)) + .resolves.toMatchObject({ sessionId: "session-1" }); + for (const invalid of [ + { ...fence, locator: { ...locator(), stateRoot: cwd } }, + { ...fence, terminalUncertain: true }, + { ...fence, ambiguous: true }, + { ...fence, processIncarnation: "linux:other" }, + { ...fence, endpointFileId: "unexpected-endpoint" }, + ]) { + const { runner } = fakeRunner([page([invalid, session()]), saved()]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toBeNull(); + } + }); + + it("uses a null SDK worktree anchor for a directory outside Git", async () => { + const row = session({ locator: { ...locator(), worktreeRoot: null } }); + const { calls, runner } = fakeRunner([page([row]), saved(row)]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner, async () => null)) + .resolves.toMatchObject({ sessionId: "session-1" }); + expect(calls[0]!.args).toEqual([...commandArgs, "--json-input", JSON.stringify({ scope: scope(cwd, null) })]); + }); + + it("resolves the matching process identity and the SDK's saved file", async () => { + const { calls, runner } = fakeRunner([page([session()]), saved()]); + + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toEqual({ + sessionId: "session-1", + pid: 321, + path: savedPath, + hostIncarnation: "linux:12345", + endpointGeneration: 4, + }); + expect(calls.map(({ command, args }) => [command, args])).toEqual([ + ["gjc", [...commandArgs, "--json-input", JSON.stringify({ scope: scope(cwd) })]], + ["gjc", [...commandArgs, "--json-input", JSON.stringify({ cwd, resolveSessionId: "session-1" })]], + ]); + }); + + it("paginates with the same limit and rejects conflicting page index sequences", async () => { + const { calls, runner } = fakeRunner([ + page([session({ pid: 999 })], { continuationCursor: "next-page" }), + page([session()]), + saved(), + ]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toMatchObject({ sessionId: "session-1" }); + expect(calls[0]!.args).toEqual([...commandArgs, "--json-input", JSON.stringify({ scope: scope(cwd) })]); + expect(calls[1]!.args).toEqual([...commandArgs, "--json-input", JSON.stringify({ scope: scope(cwd), cursor: "next-page" })]); + expect(calls[2]!.args).toEqual([...commandArgs, "--json-input", JSON.stringify({ cwd, resolveSessionId: "session-1" })]); + + const conflicting = fakeRunner([ + page([], { continuationCursor: "next-page" }), + broker({ indexSeq: 12, sessions: [session()] }), + ]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, conflicting.runner)).resolves.toBeNull(); + expect(conflicting.calls).toHaveLength(2); + }); + + it("does not choose by cwd alone when multiple pane GJC processes match", async () => { + const { calls, runner } = fakeRunner([page([session(), session({ sessionId: "session-2", pid: 322 })])]); + await expect(gjcSdkTranscriptWithRunner([321, 322], cwd, agentDir, runner)).resolves.toBeNull(); + expect(calls).toHaveLength(1); + }); + + it("scopes the scan so unrelated historical sessions beyond the page budget are excluded", async () => { + const historical = Array.from({ length: 801 }, (_, index) => session({ + sessionId: `historical-${index}`, + pid: 10_000 + index, + live: false, + locator: { cwd: join(cwd, `history-${index}`), worktreeRoot: null, stateRoot: join(cwd, ".gjc", "state") }, + })); + const calls: Call[] = []; + const runner: GjcSdkCommandRunner = async (command, args, options) => { + const call = calls.length; + calls.push({ command, args: [...args], options }); + const inputIndex = args.indexOf("--json-input"); + const input = inputIndex >= 0 ? JSON.parse(args[inputIndex + 1]!) : {}; + if (call === 0) { + const rows = [...historical, session()].filter((row) => row.locator && (row.locator as Record).cwd === input.scope?.requestAnchor.cwd); + return page(rows); + } + return saved(); + }; + + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toMatchObject({ sessionId: "session-1" }); + expect(calls[0]!.args).toEqual([...commandArgs, "--json-input", JSON.stringify({ scope: scope(cwd) })]); + expect(calls).toHaveLength(2); + }); + + it("accepts a live session alongside definitively retired rows for the same pid", async () => { + const terminal = session({ sessionId: "old-session", terminal: true, terminalUncertain: false }); + const deleted = session({ sessionId: "deleted-session", deleted: true }); + const { calls, runner } = fakeRunner([ + page([terminal, deleted, session()]), + page([terminal, deleted, session()], { savedSession: { id: "session-1", path: savedPath } }), + ]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toMatchObject({ sessionId: "session-1" }); + expect(calls).toHaveLength(2); + }); + + it("rejects competing active rows after exact lookup even when discovery was unique", async () => { + const current = session(); + const competing = session({ sessionId: "session-2" }); + const { calls, runner } = fakeRunner([ + page([current]), + page([current, competing], { savedSession: { id: "session-1", path: savedPath } }), + ]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toBeNull(); + expect(calls).toHaveLength(2); + }); + + it("uses the row's original symlinked cwd for exact resolution and matches its canonical identity", async () => { + const alias = join(cwd, "cwd-alias"); + symlinkSync(cwd, alias, process.platform === "win32" ? "junction" : "dir"); + const discoveryRow = session({ locator: { ...locator(), cwd: alias } }); + const { calls, runner } = fakeRunner([page([discoveryRow]), saved()]); + + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toMatchObject({ sessionId: "session-1" }); + expect(calls[0]!.args).toEqual([...commandArgs, "--json-input", JSON.stringify({ scope: scope(cwd) })]); + expect(calls[1]!.args).toEqual([...commandArgs, "--json-input", JSON.stringify({ cwd: alias, resolveSessionId: "session-1" })]); + }); + + it("anchors a pwd scope at its containing git worktree root", async () => { + const nestedCwd = join(cwd, "nested"); + const worktreeAlias = join(cwd, "worktree-alias"); + mkdirSync(nestedCwd); + symlinkSync(cwd, worktreeAlias, process.platform === "win32" ? "junction" : "dir"); + const row = session({ locator: { ...locator(), cwd: nestedCwd } }); + let receivedCwd = ""; + let receivedTimeout = 0; + const rootResolver: GjcSdkWorktreeRootResolver = async (requestedCwd, timeoutMs) => { + receivedCwd = requestedCwd; + receivedTimeout = timeoutMs; + return worktreeAlias; + }; + const { calls, runner } = fakeRunner([ + page([row]), + page([row], { savedSession: { id: "session-1", path: savedPath } }), + ]); + + await expect(gjcSdkTranscriptWithRunner([321], nestedCwd, agentDir, runner, rootResolver)).resolves.toMatchObject({ sessionId: "session-1" }); + expect(receivedCwd).toBe(nestedCwd); + expect(receivedTimeout).toBeGreaterThan(0); + expect(receivedTimeout).toBeLessThanOrEqual(2_000); + expect(calls[0]!.args).toEqual([ + ...commandArgs, + "--json-input", + JSON.stringify({ scope: scope(nestedCwd, cwd) }), + ]); + }); + + it("fails closed when git cannot resolve a worktree root", async () => { + let rootLookups = 0; + const rootResolver: GjcSdkWorktreeRootResolver = async () => { + rootLookups += 1; + throw new Error("Git probe is unavailable"); + }; + const { calls, runner } = fakeRunner([page([session()]), saved()]); + + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner, rootResolver)).resolves.toBeNull(); + expect(rootLookups).toBe(1); + expect(calls).toHaveLength(0); + }); + + it("distinguishes two same-cwd panes by each pane's supplied GJC pid", async () => { + const rows = [session(), session({ sessionId: "session-2", pid: 322 })]; + const { runner } = fakeRunner((args, call) => { + if (call % 2 === 0) return page(rows); + const input = JSON.parse(args[args.indexOf("--json-input") + 1]!); + const row = rows.find((candidate) => candidate.sessionId === input.resolveSessionId)!; + return saved(row, row.sessionId as string, join(cwd, `${row.sessionId}.jsonl`)); + }); + + const first = await gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner); + const second = await gjcSdkTranscriptWithRunner([322], cwd, agentDir, runner); + expect(first?.sessionId).toBe("session-1"); + expect(second?.sessionId).toBe("session-2"); + }); + + it("fails closed for multiple live rows competing for one pane process", async () => { + const { calls, runner } = fakeRunner([page([session(), session({ sessionId: "session-2" })])]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toBeNull(); + expect(calls).toHaveLength(1); + + const malformed = fakeRunner([page([session(), session({ identityProvenance: "legacy" })])]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, malformed.runner)).resolves.toBeNull(); + expect(malformed.calls).toHaveLength(1); + + const conflictingCwd = fakeRunner([page([session(), session({ locator: { ...locator(), cwd: join(cwd, "other") } })])]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, conflictingCwd.runner)).resolves.toBeNull(); + expect(conflictingCwd.calls).toHaveLength(1); + }); + + it("observes a /new session on the next call without retaining a resolved path", async () => { + const { calls, runner } = fakeRunner((_args, call) => { + const turn = Math.floor(call / 2); + const row = session({ sessionId: `session-${turn + 1}` }); + return call % 2 === 0 ? page([row]) : saved(row, `session-${turn + 1}`, join(cwd, `session-${turn + 1}.jsonl`)); + }); + + const first = await gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner); + const second = await gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner); + expect(first?.sessionId).toBe("session-1"); + expect(second).toEqual({ + sessionId: "session-2", + pid: 321, + path: join(cwd, "session-2.jsonl"), + hostIncarnation: "linux:12345", + endpointGeneration: 4, + }); + expect(calls).toHaveLength(4); + }); + + it("ignores stale, deleted, terminal, legacy, uncertain, and unrelated rows", async () => { + const invalidRows: Record[] = [ + { live: false }, + { deleted: true }, + { terminal: true }, + { terminalUncertain: true }, + { ambiguous: true }, + { identityProvenance: "legacy" }, + { endpointGeneration: 0 }, + { hostIncarnation: " " }, + { pid: 999 }, + { locator: { ...locator(), cwd: join(cwd, "other") } }, + ]; + for (const invalid of invalidRows) { + const { calls, runner } = fakeRunner([page([session(invalid)])]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toBeNull(); + expect(calls).toHaveLength(1); + } + }); + + it("rechecks the full locator, generation, and host incarnation before returning a path", async () => { + const changedRows = [ + session({ endpointGeneration: 5 }), + session({ hostIncarnation: "linux:next" }), + session({ locator: { ...locator(), stateRoot: join(cwd, "other-state") } }), + ]; + for (const changed of changedRows) { + const { calls, runner } = fakeRunner([page([session()]), saved(changed)]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toBeNull(); + expect(calls).toHaveLength(2); + } + }); + + it("rejects an exact lookup from an older broker index sequence", async () => { + const older = broker({ indexSeq: 10, sessions: [session()], savedSession: { id: "session-1", path: savedPath } }); + const { calls, runner } = fakeRunner([page([session()]), older]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toBeNull(); + expect(calls).toHaveLength(2); + }); + + it("requires the exact saved session and an absolute path", async () => { + const invalidSavedSessions = [ + undefined, + { id: "another-session", path: savedPath }, + { id: "session-1", path: "relative/session.jsonl" }, + ]; + for (const savedSession of invalidSavedSessions) { + const result = page([session()], savedSession === undefined ? {} : { savedSession }); + const { runner } = fakeRunner([page([session()]), result]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toBeNull(); + } + }); + + it("refuses an incomplete exact lookup rather than accepting its saved path", async () => { + const { runner } = fakeRunner([ + page([session()]), + page([session()], { continuationCursor: "incomplete", savedSession: { id: "session-1", path: savedPath } }), + ]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toBeNull(); + }); + + it("ends a hung SDK lookup at its deadline", async () => { + let calls = 0; + const runner: GjcSdkCommandRunner = () => { + calls += 1; + return new Promise(() => {}); + }; + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toBeNull(); + expect(calls).toBe(1); + }); + + it("rejects malformed broker output and repeated or over-budget pagination", async () => { + for (const output of ["not json", JSON.stringify({ type: "broker_response", ok: false, result: {} }), broker({ indexSeq: 1, sessions: {} })]) { + const { runner } = fakeRunner([output]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toBeNull(); + } + + const repeated = fakeRunner([page([], { continuationCursor: "same" }), page([], { continuationCursor: "same" })]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, repeated.runner)).resolves.toBeNull(); + expect(repeated.calls).toHaveLength(2); + + for (const cursor of ["", " ", 7, { token: "cursor" }]) { + const malformed = fakeRunner([page([], { continuationCursor: cursor })]); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, malformed.runner)).resolves.toBeNull(); + expect(malformed.calls).toHaveLength(1); + } + + const pages = fakeRunner((_args, call) => page([], { continuationCursor: `cursor-${call}` })); + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, pages.runner)).resolves.toBeNull(); + expect(pages.calls).toHaveLength(8); + }); + + it("keeps SDK execution bounded, non-interactive, and quiet on transport failure", async () => { + let captured: Call | undefined; + const runner: GjcSdkCommandRunner = async (command, args, options) => { + captured = { command, args: [...args], options }; + throw new Error("broker credentials and local paths must not escape"); + }; + + await expect(gjcSdkTranscriptWithRunner([321], cwd, agentDir, runner)).resolves.toBeNull(); + expect(captured?.command).toBe("gjc"); + expect(captured?.args).toEqual([...commandArgs, "--json-input", JSON.stringify({ scope: scope(cwd) })]); + expect(captured?.options.env.GJC_SDK_BROKER_AUTOSTART).toBe("0"); + expect(captured?.options.shell).toBe(false); + expect(captured?.options).not.toHaveProperty("stdio"); + expect(captured?.options.maxBuffer).toBe(1024 * 1024); + expect(captured?.options.timeout).toBeGreaterThan(0); + expect(captured?.options.timeout).toBeLessThanOrEqual(2_000); + expect(captured?.options.killSignal).toBe("SIGKILL"); + }); +}); diff --git a/server/gjc-sdk.ts b/server/gjc-sdk.ts new file mode 100644 index 000000000..057c7936b --- /dev/null +++ b/server/gjc-sdk.ts @@ -0,0 +1,363 @@ +import { execFile } from "node:child_process"; +import { realpathSync } from "node:fs"; +import nodePath from "node:path"; +import { promisify } from "node:util"; + +const PAGE_LIMIT = 100; +const MAX_PAGES = 8; +const MAX_ROWS = PAGE_LIMIT * MAX_PAGES; +const COMMAND_TIMEOUT_MS = 2_000; +const TOTAL_TIMEOUT_MS = 8_000; +const MAX_BUFFER = 1024 * 1024; +const MAX_CURSOR_LENGTH = 4096; + +export interface GjcSdkCommandOptions { + env: NodeJS.ProcessEnv; + encoding: "utf8"; + timeout: number; + killSignal: "SIGKILL"; + maxBuffer: number; + shell: false; + windowsHide: true; +} + +/** A narrow seam for fixture-driven tests; production always uses execFile below. */ +export type GjcSdkCommandRunner = (command: string, args: string[], options: GjcSdkCommandOptions) => Promise; + +export type GjcSdkWorktreeRootResolver = (cwd: string, timeoutMs: number) => Promise; + +const execFileAsync = promisify(execFile); + +const runGjcSdkCommand: GjcSdkCommandRunner = async (command, args, options) => { + const execution = execFileAsync(command, args, options); + execution.child.stdin?.end(); + const { stdout } = await execution; + return stdout; +}; + +const GIT_MAX_BUFFER = 64 * 1024; + +const gitWorktreeRoot: GjcSdkWorktreeRootResolver = async (cwd, timeoutMs) => { + const execution = execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { + encoding: "utf8", + timeout: timeoutMs, + killSignal: "SIGKILL", + maxBuffer: GIT_MAX_BUFFER, + shell: false, + windowsHide: true, + }); + execution.child.stdin?.end(); + try { + const { stdout } = await execution; + return stdout.endsWith("\r\n") ? stdout.slice(0, -2) : stdout.endsWith("\n") ? stdout.slice(0, -1) : stdout; + } catch (error) { + // Git uses 128 outside a worktree. The broker independently validates the null anchor, + // so an unrelated Git error cannot authorize a workspace with the wrong identity. + if (isRecord(error) && error.code === 128) return null; + throw error; + } +}; + +interface BrokerPage { + indexSeq: number; + sessions: unknown[]; + continuationCursor: string | null; + savedSession: unknown; +} + +interface SessionBinding { + sessionId: string; + pid: number; + hostIncarnation: string; + endpointGeneration: number; + canonicalCwd: string; + locatorCwd: string; + worktreeRoot: string | null; + stateRoot: string | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} + +function definitivelyRetired(row: Record): boolean { + return row.live === false + || row.deleted === true + || (row.terminal === true && row.terminalUncertain === false); +} + +/** + * GJC also publishes a generation-zero direct-session GC fence at the agent root. + * It has no endpoint authority and can remain live after /new. Only this proven + * shape is ignored; foreign-root, uncertain and malformed zero-generation rows + * must still prevent a binding (SDK session-index.ts: isDirectSessionGcFenceRow). + */ +function directSessionFence(row: Record, agentDir: string): boolean { + if (!isRecord(row.locator)) return false; + const root = canonicalPath(agentDir); + return root !== null && canonicalPath(row.locator.stateRoot) === root + && nonEmptyString(row.sessionId) && row.live === true + && row.endpointGeneration === 0 + && row.terminal === false && row.terminalUncertain === false && row.ambiguous === false + && row.identityProvenance === "composite" + && nonEmptyString(row.hostIncarnation) && row.processIncarnation === row.hostIncarnation + && row.endpointMtimeMs === undefined && row.endpointFileId === undefined && row.endpointIncarnation === undefined; +} + +function canonicalPath(value: unknown): string | null { + if (typeof value !== "string" || !nodePath.isAbsolute(value)) return null; + try { + const canonical = realpathSync.native(value); + return process.platform === "win32" ? canonical.toLowerCase() : canonical; + } catch { + return null; + } +} + +function parseBrokerPage(text: string): BrokerPage | null { + try { + const envelope: unknown = JSON.parse(text); + if (!isRecord(envelope) || envelope.type !== "broker_response" || envelope.ok !== true || !isRecord(envelope.result)) return null; + const result = envelope.result; + if (typeof result.indexSeq !== "number" || !Number.isSafeInteger(result.indexSeq) || result.indexSeq < 0 || !Array.isArray(result.sessions)) return null; + + let continuationCursor: string | null = null; + if (Object.hasOwn(result, "continuationCursor") && result.continuationCursor !== null) { + if (typeof result.continuationCursor !== "string" + || result.continuationCursor.length === 0 + || result.continuationCursor.length > MAX_CURSOR_LENGTH + || result.continuationCursor.trim() !== result.continuationCursor + || /[\u0000-\u001f\u007f]/.test(result.continuationCursor)) return null; + continuationCursor = result.continuationCursor; + } + + return { + indexSeq: result.indexSeq as number, + sessions: result.sessions, + continuationCursor, + savedSession: result.savedSession, + }; + } catch { + return null; + } +} + +function bindingFromRow(value: unknown, pids: ReadonlySet, cwd: string, worktreeRoot: string | null): SessionBinding | null { + if (!isRecord(value)) return null; + const locator = value.locator; + if (!isRecord(locator) + || !Object.hasOwn(locator, "worktreeRoot") + || !Object.hasOwn(locator, "stateRoot") + || !(locator.worktreeRoot === null || typeof locator.worktreeRoot === "string") + || !(locator.stateRoot === null || typeof locator.stateRoot === "string")) return null; + + const canonicalCwd = canonicalPath(locator.cwd); + if (canonicalCwd === null || canonicalCwd !== cwd || canonicalPath(locator.worktreeRoot) !== worktreeRoot) return null; + if (locator.worktreeRoot !== null && canonicalPath(locator.worktreeRoot) === null) return null; + if (typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid <= 0 || !pids.has(value.pid) + || !nonEmptyString(value.sessionId) + || typeof value.endpointGeneration !== "number" || !Number.isSafeInteger(value.endpointGeneration) || value.endpointGeneration <= 0 + || !nonEmptyString(value.hostIncarnation) + || value.identityProvenance !== "composite" + || value.live !== true + || value.terminal !== false + || value.terminalUncertain !== false + || value.ambiguous !== false + || value.deleted === true) return null; + + return { + sessionId: value.sessionId, + pid: value.pid, + hostIncarnation: value.hostIncarnation, + endpointGeneration: value.endpointGeneration, + canonicalCwd, + locatorCwd: locator.cwd as string, + worktreeRoot: locator.worktreeRoot, + stateRoot: locator.stateRoot, + }; +} + +function sameBinding(row: unknown, expected: SessionBinding, pids: ReadonlySet, cwd: string, worktreeRoot: string | null): boolean { + const actual = bindingFromRow(row, pids, cwd, worktreeRoot); + return actual !== null + && actual.sessionId === expected.sessionId + && actual.pid === expected.pid + && actual.hostIncarnation === expected.hostIncarnation + && actual.endpointGeneration === expected.endpointGeneration + && actual.canonicalCwd === expected.canonicalCwd + && actual.worktreeRoot === expected.worktreeRoot + && actual.stateRoot === expected.stateRoot; +} + +function sdkArgs(agentDir: string, jsonInput?: Record): string[] { + const args = [ + "sdk", "session", "raw", "global", + "--op", "session.list", + "--page", "--limit", String(PAGE_LIMIT), + "--agent-dir", agentDir, + "--attach-only", "--json", + ]; + if (jsonInput !== undefined) args.push("--json-input", JSON.stringify(jsonInput)); + return args; +} + +async function runBounded( + runner: GjcSdkCommandRunner, + args: string[], + deadline: number, +): Promise { + const remaining = Math.min(COMMAND_TIMEOUT_MS, deadline - Date.now()); + if (remaining <= 0) throw new Error("SDK lookup deadline exceeded"); + + const options: GjcSdkCommandOptions = { + env: { ...process.env, GJC_SDK_BROKER_AUTOSTART: "0" }, + encoding: "utf8", + timeout: remaining, + killSignal: "SIGKILL", + maxBuffer: MAX_BUFFER, + shell: false, + windowsHide: true, + }; + + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.resolve().then(() => runner("gjc", args, options)), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("SDK lookup deadline exceeded")), remaining); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +async function resolveWorktreeRoot( + resolver: GjcSdkWorktreeRootResolver, + cwd: string, + deadline: number, +): Promise { + const timeoutMs = Math.min(COMMAND_TIMEOUT_MS, deadline - Date.now()); + if (timeoutMs <= 0) return undefined; + + let timer: ReturnType | undefined; + try { + const root = await Promise.race([ + Promise.resolve().then(() => resolver(cwd, timeoutMs)), + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), timeoutMs); + }), + ]); + return root === null ? null : canonicalPath(root) ?? undefined; + } catch { + return undefined; + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +async function resolveTranscript( + pids: readonly number[], + cwd: string, + agentDir: string, + runner: GjcSdkCommandRunner, + worktreeRootResolver: GjcSdkWorktreeRootResolver, +): Promise<{ sessionId: string; pid: number; path: string; hostIncarnation: string; endpointGeneration: number } | null> { + try { + if (!Array.isArray(pids) || pids.length === 0 || pids.some((pid) => !Number.isSafeInteger(pid) || pid <= 0) + || typeof cwd !== "string" || cwd === "" + || typeof agentDir !== "string" || agentDir === "") return null; + + const deadline = Date.now() + TOTAL_TIMEOUT_MS; + const canonicalCwd = canonicalPath(cwd); + if (canonicalCwd === null) return null; + const canonicalWorktreeRoot = await resolveWorktreeRoot(worktreeRootResolver, canonicalCwd, deadline); + if (canonicalWorktreeRoot === undefined) return null; + const wantedPids = new Set(pids); + const seenCursors = new Set(); + const rows: unknown[] = []; + let indexSeq: number | null = null; + let cursor: string | null = null; + const scope = { + version: 1, + requested: "pwd", + requestAnchor: { cwd: canonicalCwd, worktreeRoot: canonicalWorktreeRoot }, + } as const; + + for (let page = 0; page < MAX_PAGES; page += 1) { + const input = cursor === null ? { scope } : { scope, cursor }; + const output = await runBounded(runner, sdkArgs(agentDir, input), deadline); + const response = parseBrokerPage(output); + if (response === null || response.sessions.length > PAGE_LIMIT) return null; + if (indexSeq !== null && response.indexSeq !== indexSeq) return null; + indexSeq = response.indexSeq; + if (rows.length + response.sessions.length > MAX_ROWS) return null; + rows.push(...response.sessions); + + cursor = response.continuationCursor; + if (cursor === null) break; + if (seenCursors.has(cursor)) return null; + seenCursors.add(cursor); + if (page === MAX_PAGES - 1) return null; + } + + const candidates: SessionBinding[] = []; + for (const row of rows) { + if (!isRecord(row) || typeof row.pid !== "number" || !wantedPids.has(row.pid) || definitivelyRetired(row)) continue; + if (directSessionFence(row, agentDir)) continue; + if (row.live !== true) return null; + const binding = bindingFromRow(row, wantedPids, canonicalCwd, canonicalWorktreeRoot); + if (binding === null) return null; + candidates.push(binding); + } + if (candidates.length !== 1) return null; + const expected = candidates[0]!; + + const lookup = await runBounded(runner, sdkArgs(agentDir, { cwd: expected.locatorCwd, resolveSessionId: expected.sessionId }), deadline); + const exact = parseBrokerPage(lookup); + if (exact === null || exact.continuationCursor !== null || indexSeq === null || exact.indexSeq < indexSeq) return null; + + const samePidRows = exact.sessions.filter((row) => isRecord(row) + && row.pid === expected.pid + && !definitivelyRetired(row) && !directSessionFence(row, agentDir)); + if (samePidRows.length !== 1 || !sameBinding(samePidRows[0], expected, wantedPids, canonicalCwd, canonicalWorktreeRoot)) return null; + if (!isRecord(samePidRows[0]) || samePidRows[0].sessionId !== expected.sessionId) return null; + if (!isRecord(exact.savedSession) || exact.savedSession.id !== expected.sessionId + || typeof exact.savedSession.path !== "string" || !nodePath.isAbsolute(exact.savedSession.path)) return null; + + return { + sessionId: expected.sessionId, + pid: expected.pid, + path: exact.savedSession.path, + hostIncarnation: expected.hostIncarnation, + endpointGeneration: expected.endpointGeneration, + }; + } catch { + // SDK stderr and process errors may include user paths; discovery must fail closed and stay quiet. + return null; + } +} + +/** Resolves the current readable GJC transcript for one of the pane's known GJC processes. */ +export function gjcSdkTranscript( + pids: readonly number[], + cwd: string, + agentDir: string, +): Promise<{ sessionId: string; pid: number; path: string; hostIncarnation: string; endpointGeneration: number } | null> { + return resolveTranscript(pids, cwd, agentDir, runGjcSdkCommand, gitWorktreeRoot); +} + +/** Fixture-injection entry point; production callers should use gjcSdkTranscript. */ +export function gjcSdkTranscriptWithRunner( + pids: readonly number[], + cwd: string, + agentDir: string, + runner: GjcSdkCommandRunner, + worktreeRootResolver: GjcSdkWorktreeRootResolver = gitWorktreeRoot, +): Promise<{ sessionId: string; pid: number; path: string; hostIncarnation: string; endpointGeneration: number } | null> { + return resolveTranscript(pids, cwd, agentDir, runner, worktreeRootResolver); +} diff --git a/server/gjc-status.test.ts b/server/gjc-status.test.ts new file mode 100644 index 000000000..bd20c241c --- /dev/null +++ b/server/gjc-status.test.ts @@ -0,0 +1,707 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { AgentStatus, HerdrPane, SessionSnapshot } from "../shared/protocol.ts"; +import { CompletionTracker, type OmoRest } from "./completion.ts"; +import { createGjcStatus, gjcPanes, gjcTurnAfter, readOmoSnapshot, reconcileProviderStatus } from "./gjc-status.ts"; +import { noTurn, OMO_ALIASES, OmoStatus, type OmoLine, type OmoPane, type OmoTurn } from "./omo-status.ts"; + +// records as gjc's native transcript writes them: a message envelope with the pi family's roles +const message = (role: string, stopReason?: string, at = "2026-10-02T00:00:10.000Z", text = "") => JSON.stringify({ type: "message", id: "x", parentId: null, timestamp: at, message: { role, content: [{ type: "text", text }], ...(stopReason ? { stopReason } : {}) } }); +// a finished background job's wake, which gjc does not always display +const asyncResult = (display = false) => JSON.stringify({ type: "custom_message", customType: "async-result", display, content: "…", timestamp: "2026-10-02T00:00:20.000Z" }); +const bookkeeping = (customType: string) => JSON.stringify({ type: "custom", customType }); +const lines = (...entries: string[]) => entries.join("\n") + "\n"; +const status = (turn: OmoTurn, entry: string) => gjcTurnAfter(turn, { text: entry }); + +const root = mkdtempSync(join(tmpdir(), "herdr-gjc-status-")); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +describe("a GJC turn, read from its transcript", () => { + it("runs from a prompt through its tool calls, and is over at an answer that stopped for good", () => { + const reduce = (...entries: string[]) => entries.reduce(status, noTurn()); + expect(reduce(message("user")).status).toBe("working"); + expect(reduce(message("user"), message("assistant", "toolUse")).status).toBe("working"); + expect(reduce(message("user"), message("assistant", "toolUse"), message("toolResult")).status).toBe("working"); + expect(reduce(message("user"), message("assistant", "toolUse"), message("toolResult"), message("assistant", "stop")).status).toBe("idle"); + expect(reduce(message("user"), message("assistant", "length")).status).toBe("idle"); + // the record's timestamp says when the turn's last word was written + expect(reduce(message("user", undefined, "2026-10-02T00:01:00.000Z")).at).toBe(Date.parse("2026-10-02T00:01:00.000Z")); + }); + + it("is at rest after an error or an abort: no finish (#687)", () => { + const reduce = (...entries: string[]) => entries.reduce(status, noTurn()); + expect(reduce(message("user"), message("assistant", "error"))).toMatchObject({ status: "idle", failed: true }); + expect(reduce(message("user"), message("assistant", "aborted"))).toMatchObject({ status: "idle", failed: true }); + // a retry that got through finishes the turn after all + const answered = reduce(message("user"), message("assistant", "error"), message("assistant", "stop")); + expect([answered.status, answered.failed ?? null]).toEqual(["idle", null]); + expect(reduce(message("user"), message("assistant", "error"), message("assistant", "toolUse")).status).toBe("working"); + }); + + it("keeps the turn an assistant message with a missing or unknown stopReason cannot decide", () => { + const reduce = (turn: OmoTurn, ...entries: string[]) => entries.reduce(status, turn); + const unknown = JSON.stringify({ type: "message", timestamp: "2026-10-02T00:00:30.000Z", message: { role: "assistant", content: [], stopReason: "suspended" } }); + const bare = JSON.stringify({ type: "message", timestamp: "2026-10-02T00:00:30.000Z", message: { role: "assistant", content: [] } }); + expect(reduce(noTurn(), unknown, bare).status).toBeNull(); + expect(reduce({ status: "working", at: null, asks: [] }, unknown, bare).status).toBe("working"); + expect(reduce({ status: "idle", at: null, asks: [] }, unknown, bare).status).toBe("idle"); + }); + + it("starts a turn on a background job's result, displayed or not, and on no other custom record", () => { + const reduce = (...entries: string[]) => entries.reduce(status, noTurn()); + const rested = [message("user"), message("assistant", "stop")]; + expect(reduce(...rested, asyncResult()).status).toBe("working"); + expect(reduce(...rested, asyncResult(true)).status).toBe("working"); + // the runtime's own bookkeeping and other agents' wake names are no gjc turn + for (const note of [bookkeeping("gjc.model_change"), bookkeeping("senpi.hooks.stop-state"), + JSON.stringify({ type: "custom_message", customType: "omo-senpi:wake", display: false }), + JSON.stringify({ type: "custom_message", customType: "async-task", display: true }), + JSON.stringify({ type: "custom_message", content: "no type named" }), + JSON.stringify({ type: "session", cwd: "/work" }), "not json"]) { + expect(reduce(...rested, note).status).toBe("idle"); + } + expect(reduce(bookkeeping("x"), "not json").status).toBeNull(); + }); + + it("starts a turn on the monitor's and the timer's hidden wakes too, and on no text that names them", () => { + const reduce = (...entries: string[]) => entries.reduce(status, noTurn()); + const rested = [message("user"), message("assistant", "stop")]; + const wake = (customType: string) => JSON.stringify({ type: "custom_message", customType, display: false, triggerTurn: true, deliverAs: "followUp", content: "…", timestamp: "2026-10-02T00:00:20.000Z" }); + for (const customType of ["async-result", "task-notification", "cron-fire"]) { + expect(reduce(...rested, wake(customType)).status).toBe("working"); + } + // a wake's name inside another record's text is no wake + expect(reduce(...rested, message("assistant", "stop", "2026-10-02T00:00:30.000Z", "the async-result arrived")).status).toBe("idle"); + expect(reduce(...rested, bookkeeping("task-notification")).status).toBe("idle"); + expect(reduce(...rested, JSON.stringify({ type: "custom_message", customType: "task-notificationed" })).status).toBe("idle"); + }); + + it("rejects a record that is not an object, and never throws on one", () => { + for (const entry of ["null", "[null]", `[${message("user")}]`, "42", "\"async-result\"", "not json", "{"]) { + expect(() => gjcTurnAfter(noTurn(), { text: entry })).not.toThrow(); + expect(gjcTurnAfter(noTurn(), { text: entry }).status).toBeNull(); + expect(gjcTurnAfter({ status: "working", at: 1, asks: [] }, { text: entry })).toMatchObject({ status: "working", at: 1 }); + } + }); + + it("holds a question the agent asked open until its result, an error result too", () => { + const asking = (id: string, extra: Record = {}) => JSON.stringify({ type: "message", timestamp: "2026-10-02T00:00:30.000Z", message: { role: "assistant", stopReason: "toolUse", content: [{ type: "toolCall", id, name: "ask", arguments: { question: "Which way?" }, ...extra }] } }); + const result = (id: string, isError = false) => JSON.stringify({ type: "message", timestamp: "2026-10-02T00:00:40.000Z", message: { role: "toolResult", toolCallId: id, toolName: "ask", content: [], isError } }); + const reduce = (...entries: string[]) => entries.reduce(status, noTurn()); + const asked = reduce(message("user"), asking("q1")); + expect(asked).toMatchObject({ status: "working" }); + expect(asked.asks.map((call) => call.id)).toEqual(["q1"]); + // unrelated assistant narration leaves the question open + expect(reduce(message("user"), asking("q1"), message("assistant", "toolUse", "2026-10-02T00:00:35.000Z", "thinking")).asks).toHaveLength(1); + // its result resumes the turn; another call's result does not + expect(reduce(message("user"), asking("q1"), result("other")).asks).toHaveLength(1); + expect(reduce(message("user"), asking("q1"), result("q1")).asks).toEqual([]); + // an error result ends the asking the same, and a call with no usable id opens none + expect(reduce(message("user"), asking("q2"), result("q2", true)).asks).toEqual([]); + expect(reduce(message("user"), asking(""), asking("q3", { incomplete: true })).asks).toEqual([]); + for (const stopped of ["stop", "error", "aborted"]) { + expect(reduce(message("user"), asking("unexecuted").replace('"stopReason":"toolUse"', `"stopReason":"${stopped}"`)).asks).toEqual([]); + } + }); + + it("reads a record of any size by its projected JSON, and only whole lines", () => { + const big = "x".repeat(200_000); + const long = message("assistant", "toolUse").replace('"content":[', `"content":[{"type":"thinking","thinking":"${big}"},`); + const ends: OmoLine = { head: long.slice(0, 4096), tail: long.slice(-4096), record: long }; + expect(gjcTurnAfter(noTurn(), ends).status).toBe("working"); + // the projection truncates long strings but stays complete JSON + const projected = JSON.parse(long.replace(big, big.slice(0, 64 * 1024))); + expect(gjcTurnAfter(noTurn(), { head: "", tail: "", record: JSON.stringify(projected) }).status).toBe("working"); + // ends without a projected record say nothing (no regex on text) + expect(gjcTurnAfter(noTurn(), { head: long.slice(0, 4096), tail: long.slice(-4096) }).status).toBeNull(); + expect(gjcTurnAfter({ status: "working", at: null, asks: [] }, { head: message("assistant", "stop").slice(0, 40), tail: message("assistant", "stop").slice(-40) }).status).toBe("working"); + }); +}); + +describe("a GJC pane's status in place of herdr's provider-child status", () => { + const pane = (id: string, agent: string | null, status: AgentStatus): HerdrPane => ({ pane_id: id, agent, agent_status: status, cwd: "/work" } as unknown as HerdrPane); + const snapshotOf = (...panes: HerdrPane[]): SessionSnapshot => ({ panes, agents: panes.map((p) => ({ pane_id: p.pane_id, agent: p.agent, agent_status: p.agent_status })) } as unknown as SessionSnapshot); + /** herdr follows the provider child: gjc's parent turn can be over while the pane reads working */ + const herdr = () => snapshotOf(pane("gjc", "gjc", "working"), pane("lost", "gjc", "working"), pane("shell", null, "unknown")); + const FILE = "/s/gjc-session.jsonl"; + + function setup(initial = lines(message("user"), message("assistant", "stop"))) { + const files: Record = { [FILE]: initial }; + const ids: Record = {}; + const told: ([string, AgentStatus, number, boolean] | [string, AgentStatus, number, boolean, OmoRest])[] = []; + const found: string[] = []; + const state = { discovered: new Map([["gjc", { path: FILE, startedAt: null }], ["lost", { path: null, startedAt: null }]]), lookups: 0, clock: 0, failing: false, during: () => {} }; + const gjc = createGjcStatus({ + discover: async () => { state.lookups += 1; state.during(); return new Map(state.discovered); }, + snapshot: async () => herdr(), + onChange: (id, status, count, turn, rest) => told.push(rest ? [id, status, count, turn, rest] : [id, status, count, turn]), + onFound: (id) => found.push(id), + file: { + stat: (path) => path in files ? { size: Buffer.byteLength(files[path]!), id: ids[path] ?? "1" } : null, + lines: (path, from, size, each) => { if (state.failing) return from; const text = Buffer.from(files[path]!).subarray(from, size).toString("utf8"); const whole = text.slice(0, text.lastIndexOf("\n") + 1); for (const line of whole.split("\n").slice(0, -1)) each({ text: line }); return from + Buffer.byteLength(whole); }, + }, + now: () => state.clock, + }); + const append = (...entries: string[]) => { files[FILE] += lines(...entries); }; + /** the file put anew in place of the old one, as a rewrite does */ + const replace = (...entries: string[]) => { files[FILE] = lines(...entries); ids[FILE] = `${Number(ids[FILE] ?? 1) + 1}`; }; + return { gjc, told, found, append, replace, state, files }; + } + const statuses = (snapshot: SessionSnapshot) => Object.fromEntries(snapshot.panes.map((p) => [p.pane_id, `${p.agent}/${p.agent_status}`])); + + it("reads READY where herdr's provider child still reads working, and no finish from a first idle baseline", async () => { + const { gjc, told, found, state } = setup(); + const completions = new CompletionTracker(null); + const raw = herdr(); + await gjc.refresh(raw.panes); + // the transcript's final answer is the truth: gjc's turn is over whatever the provider child does + expect(statuses(gjc.apply(raw))).toEqual({ gjc: "gjc/idle", lost: "gjc/working", shell: "null/unknown" }); + expect([gjc.tracks("gjc"), gjc.tracks("lost"), gjc.runs("lost"), gjc.runs("shell")]).toEqual([true, false, true, false]); + expect(found.sort()).toEqual(["gjc", "lost"]); + for (const paneId of found) completions.adopt(paneId, "gjc", []); + // a pane first known at rest never works, so its idle is no finish and no alert + expect(told).toEqual([]); + expect(statuses(completions.present(gjc.apply(raw)))["gjc"]).toBe("gjc/idle"); + // the session that cannot be told keeps herdr's word under the same identity + expect(statuses(completions.present(gjc.apply(raw)))["lost"]).toBe("gjc/working"); + state.discovered.set("gjc", { path: null, startedAt: null }); + state.clock += 10_000; + await gjc.refresh(raw.panes); + state.discovered.set("gjc", { path: FILE, startedAt: null }); + state.clock += 10_000; + await gjc.refresh(raw.panes); + expect(told).toEqual([["gjc", "idle", 0, true]]); + }); + + it("reads RUN while a turn runs and DONE when it ends, in events and in every snapshot", async () => { + const { gjc, told, append, state } = setup(); + const completions = new CompletionTracker(null); + const served = () => completions.readSnapshot(async () => { const raw = herdr(); await gjc.refresh(raw.panes); return gjc.apply(raw); }); + await served(); + append(message("user")); + gjc.poll(); + expect(told).toEqual([["gjc", "working", 0, true]]); + expect(completions.observe("gjc", "working", "gjc")).toBe("working"); + for (let i = 0; i < 3; i++) { state.clock += 10_000; expect(statuses(await served())["gjc"]).toBe("gjc/working"); } + append(message("assistant", "toolUse"), message("toolResult")); + gjc.poll(); + expect(told).toHaveLength(1); + append(message("assistant", "stop")); + gjc.poll(); + expect(told.at(-1)).toEqual(["gjc", "idle", 0, true]); + expect(completions.observe("gjc", "idle", "gjc")).toBe("done"); + for (let i = 0; i < 3; i++) { state.clock += 10_000; expect(statuses(await served())["gjc"]).toBe("gjc/done"); } + // a finished background job wakes the session: a turn of its own, though gjc displays nothing + append(asyncResult()); + gjc.poll(); + expect(completions.observe("gjc", told.at(-1)![1], "gjc")).toBe("working"); + expect(statuses(await served())["gjc"]).toBe("gjc/working"); + }); + + it("reads READY not DONE for a turn that ended in an error or an abort, and DONE once a retry answers", async () => { + const { gjc, told, append, state } = setup(lines(message("user"), message("assistant", "toolUse"), message("toolResult"))); + const completions = new CompletionTracker(null); + const served = () => completions.readSnapshot(async () => { const raw = herdr(); await gjc.refresh(raw.panes); return gjc.apply(raw); }); + await served(); + const shown: AgentStatus[] = []; + const settle = () => { for (const [id, status, , turn, rest] of told.splice(0)) if (id === "gjc" && turn) shown.push(completions.observe(id, status, "gjc", rest)); }; + append(message("assistant", "error", "2026-10-02T00:01:00.000Z")); + gjc.poll(); settle(); + expect(shown).toEqual(["working", "idle"]); + for (let i = 0; i < 3; i++) { state.clock += 10_000; expect(statuses(await served())["gjc"]).toBe("gjc/idle"); } + // a retry that gets an answer ends the turn after all: that is a finish, alerted as one + append(message("assistant", "stop", "2026-10-02T00:02:00.000Z")); + gjc.poll(); settle(); + expect(shown.slice(2)).toEqual(["done"]); + // an aborted prompt rests the same way; asked and aborted between two polls, only its rest is told + append(message("user"), message("assistant", "aborted", "2026-10-02T00:03:00.000Z")); + gjc.poll(); settle(); + expect(shown.slice(3)).toEqual(["idle"]); + expect(statuses(await served())["gjc"]).toBe("gjc/idle"); + }); + + it("leaves herdr's word alone while the transcript is missing, unreadable or holds no turn record", async () => { + const { gjc, files } = setup(); + const raw = herdr(); + await gjc.refresh(raw.panes); + expect(gjc.tracks("gjc")).toBe(true); + // the file vanished: the rest it last read is nobody's, and herdr's word stands for the pane at once + delete files[FILE]; + gjc.poll(); + expect(gjc.tracks("gjc")).toBe(false); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/working"); + // a file that was never readable + const { gjc: blind, told: blindTold, state: blindState } = setup(lines("not json", bookkeeping("gjc.model_change"))); + blindState.discovered.set("gjc", { path: "/s/elsewhere.jsonl", startedAt: null }); + await blind.refresh(raw.panes); + expect([blind.tracks("gjc"), blind.runs("gjc")]).toEqual([false, true]); + expect(statuses(blind.apply(raw))["gjc"]).toBe("gjc/working"); + expect(blindTold).toEqual([]); + // a read that fails leaves herdr's `working` standing, not an idle nobody saw + blindState.discovered.set("gjc", { path: FILE, startedAt: null }); + blindState.failing = true; + blindState.clock += 10_000; + await blind.refresh(raw.panes); + expect(blind.tracks("gjc")).toBe(false); + expect(statuses(blind.apply(raw))["gjc"]).toBe("gjc/working"); + expect(blindTold).toEqual([]); + // an empty file has no turn record either: herdr's word stands + const { gjc: empty, state: emptyState } = setup(""); + emptyState.discovered.set("gjc", { path: FILE, startedAt: null }); + await empty.refresh(raw.panes); + expect(empty.tracks("gjc")).toBe(false); + expect(statuses(empty.apply(raw))["gjc"]).toBe("gjc/working"); + }); + + it("does not take an unfinished turn of a process that is gone for a running one", async () => { + // killed after a tool call, then resumed by a new process without a prompt + const { gjc, told, state, append } = setup(lines(message("user", undefined, "2026-10-02T00:00:10.000Z"), message("assistant", "toolUse", "2026-10-02T00:00:12.000Z"))); + state.discovered.set("gjc", { path: FILE, startedAt: Date.parse("2026-10-02T00:05:00.000Z") }); + const raw = herdr(); + await gjc.refresh(raw.panes); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/idle"); + // a later lookup that cannot read the start keeps the one read before + state.discovered.set("gjc", { path: FILE, startedAt: null }); + state.clock += 10_000; + await gjc.refresh(raw.panes); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/idle"); + // the interrupted turn is read as a failed rest of the history, never a successful finish + expect(told).toEqual([["gjc", "idle", 0, true, "failed-before"]]); + // a prompt to the new process is a turn + append(message("user", undefined, "2026-10-02T00:06:00.000Z")); + gjc.poll(); + expect(told).toEqual([["gjc", "idle", 0, true, "failed-before"], ["gjc", "working", 0, true]]); + }); + + it("reads a session the pane switched to from its own start, and lets go when another agent is named", async () => { + const { gjc, told, files, state } = setup(lines(message("user"))); + const raw = herdr(); + await gjc.refresh(raw.panes); + expect(told).toEqual([["gjc", "working", 0, true]]); + // /resume puts a finished session in the pane: its last answer is no new work + const other = "/s/gjc-other.jsonl"; + files[other] = lines(message("user"), message("assistant", "error", "2026-10-02T00:02:00.000Z")); + state.discovered.set("gjc", { path: other, startedAt: null }); + state.clock += 10_000; + await gjc.refresh(raw.panes); + // an error read from a file found anew is history: it says nothing of a DONE kept since + expect(told.at(-1)).toEqual(["gjc", "idle", 0, true, "failed-before"]); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/idle"); + // Codex started in the pane: its events are no longer swallowed, and gjc lets go at once + expect(gjc.named("gjc", "codex")).toBe(true); + expect(gjc.named("gjc", "codex")).toBe(false); + expect([gjc.tracks("gjc"), gjc.runs("gjc")]).toEqual([false, false]); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/working"); + // herdr's own name for gjc, and no name, change nothing + for (const name of [null, "gjc"]) expect(gjc.named("lost", name)).toBe(false); + }); + + it("reads again what a failed read left, and only whole lines of a record still being written", async () => { + const { gjc, told, append, files, state } = setup(lines(message("user"))); + await gjc.refresh(herdr().panes); + expect(told).toEqual([["gjc", "working", 0, true]]); + state.failing = true; + append(message("assistant", "toolUse")); + gjc.poll(); + expect(told).toHaveLength(1); + state.failing = false; + gjc.poll(); + // the turn was the same, but herdr's word stood for the pane meanwhile: its own is told again + expect(told).toHaveLength(2); + // a torn tail waits for its newline: the stop record half-written is not the turn's end yet + const stop = message("assistant", "stop", "2026-10-02T00:02:00.000Z"); + files[FILE] += stop.slice(0, 60); + gjc.poll(); + expect(told).toHaveLength(2); + files[FILE] += stop.slice(60) + "\n"; + gjc.poll(); + expect(told.at(-1)).toEqual(["gjc", "idle", 0, true]); + }); + + it("reads INPUT while the agent's question waits on the user, and RUN again once its result arrives", async () => { + const ask = JSON.stringify({ type: "message", timestamp: "2026-10-02T00:00:30.000Z", message: { role: "assistant", stopReason: "toolUse", content: [{ type: "toolCall", id: "q1", name: "ask", arguments: { question: "Which way?" } }] } }); + const answer = JSON.stringify({ type: "message", timestamp: "2026-10-02T00:00:40.000Z", message: { role: "toolResult", toolCallId: "q1", toolName: "ask", content: [] } }); + const { gjc, told, append } = setup(lines(message("user"))); + const raw = herdr(); + await gjc.refresh(raw.panes); + append(ask); + gjc.poll(); + expect(told.at(-1)).toEqual(["gjc", "blocked", 0, true]); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/blocked"); + // unrelated narration leaves the question waiting + append(message("assistant", "toolUse", "2026-10-02T00:00:35.000Z", "thinking")); + gjc.poll(); + expect(told).toHaveLength(2); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/blocked"); + append(answer); + gjc.poll(); + expect(told.at(-1)).toEqual(["gjc", "working", 0, true]); + append(message("assistant", "stop")); + gjc.poll(); + expect(told.at(-1)).toEqual(["gjc", "idle", 0, true]); + const resumed = setup(lines(ask)); + await resumed.gjc.refresh(raw.panes); + resumed.state.discovered.set("gjc", { path: FILE, startedAt: Date.parse("2026-10-02T00:05:00Z") }); + resumed.state.clock += 10_000; + await resumed.gjc.refresh(raw.panes); + expect(resumed.told.at(-1)).toEqual(["gjc", "idle", 0, true, "failed"]); + resumed.append(message("user", undefined, "2026-10-02T00:06:00Z")); + resumed.gjc.poll(); + expect(statuses(resumed.gjc.apply(raw))["gjc"]).toBe("gjc/working"); + }); + + it("tells the pane's own rest once its transcript first answers, so herdr's word does not stand", async () => { + const { gjc, told, append } = setup(""); + const completions = new CompletionTracker(null); + const raw = herdr(); + // herdr's provider-child word stands for the pane meanwhile + expect(completions.observe("gjc", "working", "gjc")).toBe("working"); + await gjc.refresh(raw.panes); + expect(gjc.tracks("gjc")).toBe(false); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/working"); + expect(told).toEqual([]); + // a whole turn landed between two polls: the pane's own rest is told though nothing moved since + append(message("user"), message("assistant", "stop")); + gjc.poll(); + expect(told).toEqual([["gjc", "idle", 0, true]]); + expect(completions.observe("gjc", "idle", "gjc")).toBe("done"); + }); + + it("hands the pane back to herdr's word when the transcript cannot be read whole, and takes it back whole", async () => { + const { gjc, told, append, files, state } = setup(lines(message("user"), message("assistant", "stop"))); + const raw = herdr(); + await gjc.refresh(raw.panes); + expect(gjc.tracks("gjc")).toBe(true); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/idle"); + // gone: herdr's working stands for the pane at once + delete files[FILE]; + gjc.poll(); + expect(gjc.tracks("gjc")).toBe(false); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/working"); + // back, but unreadable: no better + const prompt = message("user", undefined, "2026-10-02T00:03:00.000Z") + "\n"; + files[FILE] = lines(message("user"), message("assistant", "stop")) + prompt.slice(0, 50); + state.failing = true; + gjc.poll(); + expect(gjc.tracks("gjc")).toBe(false); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/working"); + // and a record still being written ends nothing either + state.failing = false; + gjc.poll(); + expect(gjc.tracks("gjc")).toBe(false); + // whole again, what it gained meanwhile is told as the turn it is + files[FILE] += prompt.slice(50); + gjc.poll(); + expect(told).toEqual([["gjc", "working", 0, true]]); + expect(gjc.tracks("gjc")).toBe(true); + append(message("assistant", "stop", "2026-10-02T00:04:00.000Z")); + gjc.poll(); + expect(told.at(-1)).toEqual(["gjc", "idle", 0, true]); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/idle"); + }); + + it("tells a turn the process now holding the session could not have run as an interruption, never a finish", async () => { + const { gjc, told, state } = setup(lines(message("user", undefined, "2026-10-02T00:00:10.000Z"), message("assistant", "toolUse", "2026-10-02T00:00:12.000Z"))); + // process A ran the turn: it is live work while it holds the session + state.discovered.set("gjc", { path: FILE, startedAt: Date.parse("2026-10-02T00:00:00.000Z") }); + const completions = new CompletionTracker(null); + const raw = herdr(); + await gjc.refresh(raw.panes); + const settle = () => told.splice(0).map(([id, status, , , rest]) => completions.observe(id, status, "gjc", rest)); + expect(settle()).toEqual(["working"]); + expect(statuses(completions.present(gjc.apply(raw)))["gjc"]).toBe("gjc/working"); + // the same file, now held by a process started after the turn's last word: interrupted, not finished + state.discovered.set("gjc", { path: FILE, startedAt: Date.parse("2026-10-02T00:05:00.000Z") }); + state.clock += 10_000; + await gjc.refresh(raw.panes); + expect(settle()).toEqual(["idle"]); + expect(statuses(completions.present(gjc.apply(raw)))["gjc"]).toBe("gjc/idle"); + expect(completions.current("gjc")).toBe("idle"); + }); + + it("reads a real transcript file, whole lines at a time and again from where it stopped", async () => { + const path = join(root, "gjc-session.jsonl"); + writeFileSync(path, lines(message("user"), message("assistant", "toolUse", "2026-10-02T00:00:12.000Z", "x".repeat(200_000)))); + const told: [string, AgentStatus][] = []; + // no file injection: the shared bounded reader feeds gjc's reducer + const gjc = createGjcStatus({ + discover: async () => new Map([["gjc", { path, startedAt: null }]]), + snapshot: async () => herdr(), + onChange: (id, status) => told.push([id, status]), + }); + const raw = herdr(); + await gjc.refresh(raw.panes); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/working"); + appendFileSync(path, lines(message("toolResult"), asyncResult(true), message("assistant", "stop", "2026-10-02T00:02:00.000Z"))); + gjc.poll(); + expect(told).toEqual([["gjc", "working"], ["gjc", "idle"]]); + expect(statuses(gjc.apply(raw))["gjc"]).toBe("gjc/idle"); + }); + + it("keeps the shared tracker's OmO behavior when no GJC options are given", async () => { + // a default OmoStatus: `omo` identity and aliases, the OmO reducer, an empty file still authoritative + const omoFiles: Record = { [FILE]: "" }; + const told: [string, AgentStatus][] = []; + const omo = new OmoStatus({ + discover: async () => new Map([["gjc", { path: FILE, startedAt: null }]]), + snapshot: async () => herdr(), + onChange: (id, status) => told.push([id, status]), + file: { + stat: (path) => path in omoFiles ? { size: Buffer.byteLength(omoFiles[path]!), id: "1" } : null, + lines: (_path, from) => from, + }, + }); + const raw = snapshotOf(pane("gjc", "claude", "idle")); + await omo.refresh(raw.panes); + expect(omo.tracks("gjc")).toBe(true); + expect(statuses(omo.apply(raw))["gjc"]).toBe("omo/idle"); + expect(omo.named("gjc", "claude")).toBe(false); + expect(omo.named("gjc", "codex")).toBe(true); + expect(told).toEqual([]); + }); +}); + +describe("native OmO ownership alongside GJC", () => { + function setup(native = true) { + const cwd = mkdtempSync(join(root, "ownership-")); + const omoPath = join(cwd, "2026-10-02_native-omo-session.jsonl"); + const gjcPath = join(cwd, "gjc.jsonl"); + writeFileSync(omoPath, lines(message("user"))); + writeFileSync(gjcPath, lines(message("user"), message("assistant", "stop"))); + const state = { native, known: true, clock: 0, duringOmo: async () => {}, duringGjc: async () => {} }; + const pane = (paneId: string): HerdrPane => ({ pane_id: paneId, agent: "gjc", agent_status: "working", cwd, focused: false, revision: 0, tab_id: "t", terminal_id: paneId, workspace_id: "w" }); + const raw: SessionSnapshot = { panes: [pane("wrapped"), pane("standalone")], agents: [pane("wrapped"), pane("standalone")], layouts: [], tabs: [], workspaces: [], protocol: 22, version: "test" }; + const completions = new CompletionTracker(null); + const adopted: string[] = []; + const turns: [string, AgentStatus][] = []; + const candidates: string[][] = []; + const omo = new OmoStatus({ + discover: async () => { await state.duringOmo(); return state.native ? new Map([["wrapped", { path: state.known ? omoPath : null, startedAt: null }]]) : new Map(); }, + snapshot: async () => raw, + onFound: (id) => completions.adopt(id, "omo", OMO_ALIASES), + onChange: (id, status, _background, turn, rest) => { if (turn) completions.observe(id, status, "omo", rest); }, + background: () => new Map([["native-omo-session", 3]]), + now: () => state.clock, + }); + const snapshot = () => readOmoSnapshot(async () => raw, omo); + const gjc = createGjcStatus({ + snapshot, + discover: async (panes) => { + candidates.push(panes.map((pane) => pane.pane_id)); + const found = await gjcPanes(panes, { processInfo: async () => null, transcript: async () => gjcPath, underShell: async () => null }); + await state.duringGjc(); + return found; + }, + onFound: (id) => { adopted.push(id); completions.adopt(id, "gjc", []); }, + onChange: (id, status, _background, turn, rest) => { turns.push([id, status]); if (turn) completions.observe(id, status, "gjc", rest); }, + now: () => state.clock, + }, omo); + const served = () => completions.readSnapshot(async () => { + const named = await snapshot(); + await gjc.refresh(named.panes); + return gjc.apply(omo.apply(named)); + }); + return { omo, gjc, state, raw, snapshot, served, adopted, turns, candidates, completions, omoPath, gjcPath }; + } + + it("preserves native status and background work when the raw pane is gjc", async () => { + const f = setup(); + const shown = await f.served(); + expect(shown.panes.find((pane) => pane.pane_id === "wrapped")).toMatchObject({ agent: "omo", agent_status: "working" }); + expect(shown.agents.find((pane) => pane.pane_id === "wrapped")).toMatchObject({ agent: "omo", agent_status: "working" }); + expect(f.omo.backgroundOf("wrapped")).toBe(3); + expect(f.gjc.runs("wrapped")).toBe(false); + expect(f.adopted).toEqual(["standalone"]); + expect(f.turns).toEqual([]); + expect(shown.panes.find((pane) => pane.pane_id === "standalone")).toMatchObject({ agent: "gjc", agent_status: "idle" }); + expect(f.gjc.tracks("standalone")).toBe(true); + }); + + it("uses native ownership when periodic discovery invokes the shared snapshot dependency", async () => { + const f = setup(); + let release = () => {}; + let entered = () => {}; + const discovered = new Promise((resolve) => { release = resolve; }); + const discovering = new Promise((resolve) => { entered = resolve; }); + f.state.duringOmo = async () => { entered(); await discovered; }; + const periodic = f.snapshot().then((snapshot) => f.gjc.refresh(snapshot.panes)); + await discovering; + await Promise.resolve(); + expect(f.candidates).toEqual([]); + release(); + await periodic; + expect(f.candidates).toEqual([["standalone"]]); + expect(f.adopted).toEqual(["standalone"]); + expect(f.turns).toEqual([]); + expect(f.omo.runs("wrapped")).toBe(true); + expect(f.omo.backgroundOf("wrapped")).toBe(3); + }); + + it("hands ownership back to GJC when native discovery loses OmO", async () => { + const f = setup(); + await f.served(); + f.state.native = false; + f.state.clock += 10_000; + const shown = await f.served(); + expect(f.omo.runs("wrapped")).toBe(false); + expect(f.gjc.tracks("wrapped")).toBe(true); + expect(f.adopted).toEqual(["standalone", "wrapped"]); + expect(shown.panes.find((pane) => pane.pane_id === "wrapped")).toMatchObject({ agent: "gjc", agent_status: "idle" }); + appendFileSync(f.gjcPath, lines(message("user"))); + f.gjc.poll(); + expect(f.turns).toContainEqual(["wrapped", "working"]); + }); + + it("keeps the new GJC turn when a raw event clears inactive OmO history", async () => { + const f = setup(); + await f.served(); + f.state.native = false; + f.state.clock += 10_000; + await f.served(); + appendFileSync(f.gjcPath, lines(message("user"))); + f.gjc.poll(); + if (reconcileProviderStatus(f.omo, f.gjc, { pane_id: "wrapped", agent: "gjc" })) f.completions.forget("wrapped"); + expect(f.gjc.tracks("wrapped")).toBe(true); + expect(f.completions.current("wrapped")).toBe("working"); + }); + + it("rejects a stale discovery result when native OmO is found during the GJC lookup", async () => { + const f = setup(false); + f.state.duringGjc = async () => { f.state.native = true; await f.omo.refresh(f.raw.panes); }; + await f.gjc.refresh(f.raw.panes); + expect(f.adopted).toEqual(["standalone"]); + expect(f.turns).toEqual([]); + expect(f.completions.current("wrapped")).toBe("working"); + expect(f.gjc.runs("wrapped")).toBe(false); + expect(f.omo.backgroundOf("wrapped")).toBe(3); + }); + + it("suppresses a stale GJC poll after native OmO takes its pane", async () => { + const f = setup(false); + await f.served(); + f.state.native = true; + f.state.clock += 10_000; + await f.omo.refresh(f.raw.panes); + appendFileSync(f.omoPath, lines(message("assistant", "stop"))); + f.omo.poll(); + appendFileSync(f.gjcPath, lines(message("user"))); + f.gjc.poll(); + expect(f.turns).toEqual([["standalone", "working"]]); + expect(f.completions.current("wrapped")).toBe("done"); + expect(f.omo.backgroundOf("wrapped")).toBe(3); + }); + + it.each(["gjc", "codex", "devin", "unknown", null])("preserves native ownership when a stale %s provider event arrives", async (agent) => { + const f = setup(false); + await f.served(); + f.state.native = true; + f.state.clock += 10_000; + await f.omo.refresh(f.raw.panes); + appendFileSync(f.omoPath, lines(message("assistant", "stop"))); + f.omo.poll(); + if (reconcileProviderStatus(f.omo, f.gjc, { pane_id: "wrapped", agent })) f.completions.forget("wrapped"); + expect(f.omo.runs("wrapped")).toBe(true); + expect(f.gjc.runs("wrapped")).toBe(false); + expect(f.completions.current("wrapped")).toBe("done"); + expect(f.omo.backgroundOf("wrapped")).toBe(3); + }); + + it("keeps native ownership with an unknown session while raw status remains authoritative", async () => { + const f = setup(); + f.state.known = false; + await f.served(); + if (reconcileProviderStatus(f.omo, f.gjc, { pane_id: "wrapped", agent: "codex" })) f.completions.forget("wrapped"); + const shown = await f.served(); + expect(f.omo.runs("wrapped")).toBe(true); + expect(f.omo.tracks("wrapped")).toBe(false); + expect(f.adopted).toEqual(["standalone"]); + expect(shown.panes.find((pane) => pane.pane_id === "wrapped")).toMatchObject({ agent: "omo", agent_status: "working" }); + }); + + it("releases standalone GJC when another provider is named", async () => { + const f = setup(); + await f.served(); + expect(reconcileProviderStatus(f.omo, f.gjc, { pane_id: "standalone", agent: "codex" })).toBe(true); + expect(f.gjc.runs("standalone")).toBe(false); + }); + + it("returns to raw unknown status when native OmO disappears without another agent", async () => { + const f = setup(); + await f.served(); + f.state.native = false; + f.state.clock += 10_000; + for (const pane of [...f.raw.panes, ...f.raw.agents]) { + if (pane.pane_id === "wrapped") { pane.agent = null; pane.agent_status = "unknown"; } + } + const shown = await f.served(); + expect(f.omo.runs("wrapped")).toBe(false); + expect(f.gjc.runs("wrapped")).toBe(false); + expect(shown.panes.find((pane) => pane.pane_id === "wrapped")).toMatchObject({ agent: null, agent_status: "unknown" }); + }); +}); + +describe("the gjc process a pane's transcript and start are read from", () => { + const gjcPane = (id: string, extra: Record = {}): HerdrPane => + ({ pane_id: id, agent: "gjc", agent_status: "working", cwd: "/work", ...extra } as unknown as HerdrPane); + const transcript = async (id: string, _cwd: string) => `/t/${id}.jsonl`; + + it("binds the pane's current runtime cwd, not where it started", async () => { + const asked: string[] = []; + const panes = await gjcPanes([gjcPane("p", { foreground_cwd: "/work/now" }), gjcPane("q")], { + processInfo: async () => null, + transcript: async (_id, cwd) => { asked.push(cwd); return null; }, + underShell: async () => null, + }); + expect(asked.sort()).toEqual(["/work", "/work/now"]); + // a transcript nobody could tell is still the pane's own record: herdr's word stands for it + expect([...panes.values()]).toEqual([{ path: null, startedAt: null }, { path: null, startedAt: null }]); + }); + + it("takes the native start from the gjc in the foreground, and only there", async () => { + const seen: number[] = []; + const panes = await gjcPanes([gjcPane("p")], { + processInfo: async () => ({ process_info: { foreground_processes: [ + { pid: 11, argv: ["node", "/home/u/.gjc/gjc"] }, + { pid: 12, argv: ["/usr/bin/claude", "--session", "abc"] }, + ] } }), + transcript, + startedAt: (pid) => { seen.push(pid); return pid === 11 ? 1_000 : 2_000; }, + underShell: async () => null, + }); + expect(seen).toEqual([11]); + expect(panes.get("p")).toEqual({ path: "/t/p.jsonl", startedAt: 1_000 }); + }); + + it("takes the gjc under the pane's shell on Windows, with the start the table read for it", async () => { + const panes = await gjcPanes([gjcPane("p")], { + platform: "win32", + processInfo: async () => ({ process_info: { shell_pid: 50, foreground_processes: [{ pid: 50, argv: ["pwsh.exe"] }] } }), + transcript, + underShell: async (shellPid) => shellPid === 50 ? { pid: 77, started: 4_242 } : null, + }); + expect(panes.get("p")).toEqual({ path: "/t/p.jsonl", startedAt: 4_242 }); + }); + + it("says nothing of a start when the process table could not be read at all", async () => { + const panes = await gjcPanes([gjcPane("p")], { + processInfo: async () => null, + transcript, + underShell: async () => undefined, + }); + expect(panes.get("p")).toEqual({ path: "/t/p.jsonl", startedAt: null }); + }); + + it("takes no start from the provider child herdr follows", async () => { + // a foreground that holds only the provider child: its pid is never the session's start + const panes = await gjcPanes([gjcPane("p")], { + processInfo: async () => ({ process_info: { foreground_processes: [{ pid: 88, argv: ["/usr/bin/claude"] }] } }), + transcript, + startedAt: () => 9_999, + underShell: async () => null, + }); + expect(panes.get("p")).toEqual({ path: "/t/p.jsonl", startedAt: null }); + }); +}); diff --git a/server/gjc-status.ts b/server/gjc-status.ts new file mode 100644 index 000000000..96cbe4843 --- /dev/null +++ b/server/gjc-status.ts @@ -0,0 +1,179 @@ +/** + * The status of a GJC pane, read from gjc's own transcript. + * + * herdr follows the pane's provider child, not the gjc parent: a turn gjc finished keeps + * reading `working` for what the child does, and the sidebar holds RUN with no done alert. + * The transcript the pane's gjc process holds (gjc-runtime.ts finds exactly that file, never a + * newest file by cwd) tells the parent turn instead: a user prompt, a tool result or an + * assistant message that stopped for a tool means work; one that stopped for good (`stop`, + * `length`) is rest, and one that errored or was aborted is rest after a failure — READY, not + * DONE (#687's rule holds here too). gjc wakes the agent with a custom message when work it + * asked for lands elsewhere — a background job's `async-result`, a monitor's + * `task-notification`, a timer's `cron-fire` — displayed or not: that begins a new parent + * turn. Everything else gjc writes (custom bookkeeping, model notices) is no turn's business. + * A tool call named `ask` holds a question for the user open until its result arrives, an + * error result included: while one is open the pane reads INPUT, whatever the turn does. + * + * The status replaces herdr's for the pane only once a turn record was actually read and the + * read is current: an empty, missing, unreadable or half-written transcript proves nothing, + * so herdr's `working` stands rather than a rest no one saw. The machinery is omo-status.ts's, + * composed with the `gjc` identity and this file's reducer; gjc keeps no background-task + * records to count. A turn the process holding the session now could not have run (it started + * after the record was written) is another process's unfinished work: an interruption, told as + * a failed rest, never a finish. + */ +import type { HerdrPane, SessionSnapshot } from "../shared/protocol.ts"; +import { herdrRpc } from "./herdr/client.ts"; +import { gjcPidUnderShell, gjcTranscriptForPane, isGjcProcess, type GjcProcess } from "./gjc-runtime.ts"; +import type { OmoAsks } from "./omo-ask.ts"; +import { processStartedAt } from "./process-start.ts"; +import { OmoStatus, type OmoLine, type OmoPane, type OmoStatusDeps, type OmoTurn } from "./omo-status.ts"; + +/** the custom messages gjc wakes the parent turn with, displayed or not */ +const WAKE_TYPES = new Set(["async-result", "task-notification", "cron-fire"]); + +interface GjcEntry { + type?: unknown; + customType?: unknown; + timestamp?: unknown; + message?: { role?: unknown; stopReason?: unknown; content?: unknown; toolCallId?: unknown }; +} + +/** + * The questions a gjc turn holds open for the user: an `ask` call opens one until the result + * for its id arrives — an error result ends the asking the same, since nothing waits on the + * user then. Assistant narration that carries no call leaves every question as it was. + */ +function gjcAsksAfter(open: OmoAsks, entry: GjcEntry): OmoAsks { + if (entry.type !== "message") return open; + const message = entry.message; + if (message?.role === "assistant" && message.stopReason === "toolUse") { + const calls = (Array.isArray(message.content) ? message.content as Record[] : []) + .filter((part) => part?.["type"] === "toolCall" && part["name"] === "ask" && typeof part["id"] === "string" && part["id"] !== "" && part["incomplete"] !== true) + .map((part) => ({ id: part["id"] as string, wait: true, args: part["arguments"] })); + const ids = new Set(calls.map((call) => call.id)); + return calls.length > 0 ? [...open.filter((call) => !ids.has(call.id)), ...calls] : open; + } + if (message?.role === "toolResult" && typeof message.toolCallId === "string") { + return open.filter((call) => call.id !== message.toolCallId); + } + return open; +} + +/** + * One transcript record applied to the parent turn so far, from a whole line or a projected + * record's complete JSON (never its text: a record too long to hold says nothing). An + * assistant message without a readable stopReason keeps the turn as it was; a record that is + * not an object (a bare value, a list) is no record at all. + */ +export function gjcTurnAfter(turn: OmoTurn, line: OmoLine): OmoTurn { + const text = "text" in line ? line.text : line.record; + if (text === undefined) return turn; + let entry: GjcEntry; + try { entry = JSON.parse(text); } catch { return turn; } + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return turn; + const parsed = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : NaN; + const at = Number.isFinite(parsed) ? parsed : turn.at; + const asks = gjcAsksAfter(turn.asks, entry); + if (entry.type === "custom_message") { + return typeof entry.customType === "string" && WAKE_TYPES.has(entry.customType) ? { status: "working", at, asks } : { ...turn, asks }; + } + if (entry.type !== "message") return { ...turn, asks }; + const role = entry.message?.role; + const stopReason = entry.message?.stopReason; + if (role === "user" || role === "toolResult") return { status: "working", at, asks }; + if (role !== "assistant") return { ...turn, asks }; + if (stopReason === "toolUse") return { status: "working", at, asks }; + if (stopReason === "stop" || stopReason === "length") return { status: "idle", at, asks }; + if (stopReason === "error" || stopReason === "aborted") return { status: "idle", failed: true, at, asks }; + return { ...turn, asks }; +} + +type ProcessInfo = { process_info?: { shell_pid?: unknown; foreground_processes?: { pid?: unknown; argv?: unknown }[] } } | null; + +/** + * How `gjcPanes` finds out what it needs, each part unset meaning the real lookup: the pane's + * process info and its transcript (herdr's RPCs), when a pid started (process-start.ts), the + * gjc under a Windows pane's shell (gjc-runtime.ts), and the platform that decides between the + * two process identities. Tests give their own, so no live RPC is needed to tell the cases. + */ +export interface GjcDiscovery { + processInfo?: (paneId: string) => Promise; + transcript?: (paneId: string, cwd: string) => Promise; + startedAt?: (pid: number) => number | null; + underShell?: (shellPid: unknown) => Promise; + platform?: string; +} + +/** + * The panes of a snapshot herdr labels `gjc`, with the exact transcript each holds (null when + * it cannot be told: herdr's status stands for the pane) and when its native foreground gjc + * process started, so a turn a resumed session left unfinished is not taken for running work. + * On Windows herdr names only the pane's shell: the gjc under it, and its start, is the PC's + * process table's answer. `agent_session` is never consulted: it may name the provider child, + * not the gjc parent. The transcript is bound at the pane's current runtime cwd — what the + * foreground process runs in now, not where the pane started — the same cwd the chat binds by. + */ +export async function gjcPanes(panes: HerdrPane[], deps: GjcDiscovery = {}): Promise> { + const processInfo = deps.processInfo ?? ((paneId: string) => herdrRpc>("pane.process_info", { pane_id: paneId }).catch(() => null)); + const transcript = deps.transcript ?? ((paneId: string, cwd: string) => gjcTranscriptForPane(paneId, cwd).catch(() => null)); + const startedAt = deps.startedAt ?? processStartedAt; + const underShell = deps.underShell ?? ((shellPid: unknown) => gjcPidUnderShell(shellPid, deps.platform)); + const found = new Map(); + await Promise.all(panes.filter((pane) => pane.agent === "gjc").map(async (pane) => { + const info = await processInfo(pane.pane_id); + const starts = (info?.process_info?.foreground_processes ?? []) + .filter((process) => typeof process.pid === "number" && Array.isArray(process.argv) && isGjcProcess(process.argv.map(String))) + .map((process) => startedAt(process.pid as number)) + .filter((start): start is number => start !== null); + let started = starts.length > 0 ? Math.min(...starts) : null; + if (started === null) { + const native = await underShell(info?.process_info?.shell_pid).catch(() => undefined); + if (native) started = native.started; + } + const path = await transcript(pane.pane_id, pane.foreground_cwd || pane.cwd || ""); + found.set(pane.pane_id, { path, startedAt: started }); + })); + return found; +} + +/** + * The tracker's GJC composition: the `gjc` identity (herdr gives it no other name, so no + * aliases), this file's reducer, no background counts, an unfinished turn of a process that is + * gone told as the failed rest it is, and the readiness gate that releases a transcript + * nothing — or not everything — was read from back to herdr's status. + */ +export function createGjcStatus(deps: Omit, nativeOmo?: OmoStatus): OmoStatus { + return new OmoStatus({ + ...deps, + discover: async (panes) => { + const found = await deps.discover(panes.filter((pane) => !nativeOmo?.runs(pane.pane_id))); + // Native discovery may have finished while gjc's process lookup was in flight. + for (const paneId of found.keys()) if (nativeOmo?.runs(paneId)) found.delete(paneId); + return found; + }, + onFound: (paneId) => { if (!nativeOmo?.runs(paneId)) deps.onFound?.(paneId); }, + onChange: (...change) => { if (!nativeOmo?.runs(change[0])) deps.onChange(...change); }, + agent: "gjc", + aliases: [], + turnAfter: gjcTurnAfter, + ready: ({ turn, available, complete }) => turn.status !== null && available && complete, + background: () => new Map(), + staleIsFailure: true, + }); +} + +/** Shared by GJC's periodic discovery and the combined snapshot: native OmO is found first. */ +export async function readOmoSnapshot(snapshot: OmoStatusDeps["snapshot"], omo: OmoStatus): Promise { + const raw = await snapshot(); + await omo.refresh(raw.panes); + return omo.apply(raw); +} + +/** Provider labels cannot revoke native OmO; only discovery can. Discard any stale GJC hold. */ +export function reconcileProviderStatus(omo: OmoStatus, gjc: OmoStatus, pane: Pick): boolean { + const native = omo.runs(pane.pane_id); + const leftOmo = !native && omo.named(pane.pane_id, pane.agent ?? null); + const leftGjc = gjc.named(pane.pane_id, native ? "omo" : pane.agent ?? null); + return !native && (leftGjc || (leftOmo && !gjc.runs(pane.pane_id))); +} diff --git a/server/gjc.contract.test.ts b/server/gjc.contract.test.ts index 5d32021f8..7dddf4659 100644 --- a/server/gjc.contract.test.ts +++ b/server/gjc.contract.test.ts @@ -1,23 +1,79 @@ import { afterAll, expect, it } from "bun:test"; -import { mkdirSync, mkdtempSync, readdirSync, readlinkSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readdirSync, readlinkSync, realpathSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { herdrRpc, herdrSocketPath, sessionSnapshot, workspaceClose, workspaceCreate, paneRead } from "./herdr/client.ts"; -import { ConversationUnavailable, gjcTranscriptPath, paneConversation, transcriptPage } from "./conversation.ts"; - -import { gjcTerminal, isGjcProcess } from "./gjc-runtime.ts"; +import { ConversationUnavailable, gjcTranscriptPath, paneConversation } from "./conversation.ts"; +import { isGjcProcess } from "./gjc-runtime.ts"; import { startShellAgent } from "./shell-agent.ts"; -const home = mkdtempSync(join(tmpdir(), "herdr-gjc-binding-")); -const dir = join(home, ".gjc", "agent", "sessions", "v2-shared"); +const home = realpathSync(mkdtempSync(join(tmpdir(), "herdr-gjc-binding-"))); +const agentDir = join(home, ".gjc", "agent"); +const dir = join(agentDir, "sessions", "v2-shared"); mkdirSync(dir, { recursive: true }); const workspaces: string[] = []; const script = join(home, "gjc"); -// The stand-in keeps actual descriptors open; all files and panes belong to this test. -writeFileSync(script, "if (process.env.GJC_TEST_CWD) process.chdir(process.env.GJC_TEST_CWD);\nfor (const path of process.argv.slice(2)) require('node:fs').openSync(path, 'r');\nconsole.log('GJC descriptor ready'); console.log(process.env.GJC_TEST_SCREEN || ''); setInterval(() => {}, 1000);\n"); +const bin = join(home, "bin"); +const registry = join(home, "sdk-registry.json"); +const sdkCli = join(home, "gjc-sdk-fixture.js"); +const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + +// The stand-in is a Bun script named gjc; it holds only the requested store directory and prints the pane's fixed screen. +writeFileSync(script, "if (process.env.GJC_TEST_CWD) process.chdir(process.env.GJC_TEST_CWD);\nfor (const path of process.argv.slice(2)) require('node:fs').openSync(path, 'r');\nconsole.log('GJC fixture ready'); console.log(process.env.GJC_TEST_SCREEN || ''); setInterval(() => {}, 1000);\n"); +mkdirSync(bin); +// The fixture accepts only the public raw global session.list operation. Its registry is test-owned JSON, never SDK state. +writeFileSync(sdkCli, ` +const { readFileSync } = require("node:fs"); +const args = process.argv.slice(2); +const value = (flag) => { const i = args.indexOf(flag); return i < 0 ? undefined : args[i + 1]; }; +if (args[0] !== "sdk" || args[1] !== "session" || args[2] !== "raw" || args[3] !== "global" + || args[4] !== "--op" || args[5] !== "session.list" + || !args.includes("--attach-only") || !args.includes("--json") + || value("--agent-dir") !== process.env.GJC_TEST_AGENT_DIR + || !args.includes("--page") || value("--limit") !== "100") process.exit(64); +let state; +try { state = JSON.parse(readFileSync(process.env.GJC_TEST_REGISTRY, "utf8")); } +catch { process.exit(1); } +if (state.kind === "unavailable") { + process.stdout.write(JSON.stringify({ type: "broker_response", ok: false, error: { code: "broker_unavailable" } })); + process.exit(1); +} +if (state.kind === "malformed") { process.stdout.write("not-json"); process.exit(0); } +let input; +try { if (value("--json-input") !== undefined) input = JSON.parse(value("--json-input")); } +catch { process.exit(64); } +let sessions = state.rows; +let savedSession; +let savedSessionOmission; +if (input && input.scope) { + const scope = input.scope; + if (scope.version !== 1 || scope.requested !== "pwd" || !scope.requestAnchor + || typeof scope.requestAnchor.cwd !== "string" || scope.requestAnchor.worktreeRoot !== null) process.exit(64); + sessions = sessions.filter((row) => row.locator.cwd === scope.requestAnchor.cwd); +} +if (input && typeof input.resolveSessionId === "string") { + sessions = sessions.filter((row) => row.sessionId === input.resolveSessionId && row.locator.cwd === input.cwd); + const path = state.paths && state.paths[input.resolveSessionId]; + if (typeof path === "string") savedSession = { id: input.resolveSessionId, path }; + else savedSessionOmission = { reason: "not_found", detailCode: "saved_session_omitted" }; +} +const result = { indexSeq: 1, sessions, warnings: [] }; +if (savedSession !== undefined) result.savedSession = savedSession; +if (savedSessionOmission !== undefined) result.savedSessionOmission = savedSessionOmission; +process.stdout.write(JSON.stringify({ type: "broker_response", ok: true, result })); +`); +// Non-SDK invocations are the executable-path contract's pane stand-in, not another SDK response path. +writeFileSync(join(bin, "gjc"), `#!/bin/sh\nif [ "$1" = "sdk" ]; then exec ${shellQuote(process.execPath)} ${shellQuote(sdkCli)} "$@"; fi\nexec ${shellQuote(process.execPath)} ${shellQuote(script)} "$@"\n`, { mode: 0o755 }); + afterAll(async () => { - for (const workspace of workspaces) await workspaceClose(workspace); - rmSync(home, { recursive: true, force: true }); + const previousSocket = process.env["HERDR_SOCKET"]; + process.env["HERDR_SOCKET"] = herdrSocketPath(); + try { + for (const workspace of workspaces) await workspaceClose(workspace); + } finally { + if (previousSocket === undefined) delete process.env["HERDR_SOCKET"]; else process.env["HERDR_SOCKET"] = previousSocket; + rmSync(home, { recursive: true, force: true }); + } }); /** Paths a process holds open: /proc on Linux, lsof on macOS. */ @@ -33,195 +89,270 @@ async function pane(paths: string[], screen = "", cwd = home): Promise { const created = await workspaceCreate({ cwd: home, label: "herdr-web-ui-test-gjc-binding" }); workspaces.push(created.workspace.workspace_id); const id = created.root_pane.pane_id; - await herdrRpc("pane.send_text", { pane_id: id, text: `GJC_TEST_CWD='${cwd.replaceAll("'", "'\\''")}' GJC_TEST_SCREEN='${screen.replaceAll("'", "'\\''")}' ${process.execPath} ${script} ${paths.join(" ")}\n` }); + const args = paths.map(shellQuote).join(" "); + const command = `GJC_TEST_CWD=${shellQuote(cwd)} GJC_TEST_SCREEN=${shellQuote(screen)} ${shellQuote(process.execPath)} ${shellQuote(script)} ${args}\n`; + await herdrRpc("pane.send_text", { pane_id: id, text: command }); for (let attempt = 0; attempt < 100; attempt++) { const info = await herdrRpc<{ process_info?: { foreground_processes?: { pid: number; argv?: string[] }[] } }>("pane.process_info", { pane_id: id }); - const process = info.process_info?.foreground_processes?.find((p) => p.argv?.includes(script)); - if (process) { - const held = heldPaths(process.pid); - if (paths.every((path) => held.includes(path))) return id; - } + const process = info.process_info?.foreground_processes?.find((candidate) => candidate.argv?.includes(script)); + if (process && paths.every((path) => heldPaths(process.pid).includes(path))) return id; await Bun.sleep(50); } - throw new Error("GJC stand-in did not open its descriptors"); + throw new Error("GJC stand-in did not open its store descriptor in the pane"); } -// the stand-in is found by the file it holds open (/proc): on macOS gjc leaves a terminal breadcrumb instead -it.skipIf(process.platform !== "linux")("reads the foreground GJC session after its process changes directory, not another session in the pane cwd", async () => { - const cwd = join(home, "project"); - mkdirSync(cwd); - const path = join(dir, "foreground.jsonl"), other = join(dir, "pane-cwd.jsonl"); - for (const [file, directory, answer] of [[path, cwd, "foreground answer"], [other, home, "wrong pane cwd answer"]]) { - writeFileSync(file!, JSON.stringify({ type: "session", cwd: directory }) + "\n" + JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: answer }] } }) + "\n"); - } - const id = await pane([path], "", cwd); - await herdrRpc("pane.report_agent", { pane_id: id, source: "manual", agent: "gjc", state: "idle" }); +async function gjcProcess(id: string): Promise<{ pid: number; argv: string[] }> { + const info = await herdrRpc<{ process_info?: { foreground_processes?: { pid?: number; argv?: string[] }[] } }>("pane.process_info", { pane_id: id }); + const found = info.process_info?.foreground_processes?.find((candidate) => candidate.argv?.includes(script) && isGjcProcess(candidate.argv)); + if (typeof found?.pid !== "number" || !found.argv) throw new Error("pane.process_info did not expose the Bun GJC stand-in"); + return { pid: found.pid, argv: found.argv }; +} + +async function waitForScreen(id: string, text: string): Promise { for (let attempt = 0; attempt < 100; attempt++) { - if ((await sessionSnapshot()).panes.find((p) => p.pane_id === id)?.agent === "gjc") break; + if ((await paneRead({ paneId: id })).text.includes(text)) return; await Bun.sleep(50); } - const metadata = (await sessionSnapshot()).panes.find((p) => p.pane_id === id)!; - expect(metadata.agent).toBe("gjc"); - expect(metadata.cwd).toBe(home); - expect(metadata.foreground_cwd).toBe(cwd); - const oldHome = process.env["HOME"], oldSocket = process.env["HERDR_SOCKET"]; - process.env["HERDR_SOCKET"] = herdrSocketPath(); - process.env["HOME"] = home; - try { - const conversation = await paneConversation(id); - expect(conversation.source).toBe("gjc-transcript"); - expect(conversation.turns[0]!.parts[0]).toMatchObject({ kind: "text", text: "foreground answer" }); - } finally { - if (oldHome === undefined) delete process.env["HOME"]; else process.env["HOME"] = oldHome; - if (oldSocket === undefined) delete process.env["HERDR_SOCKET"]; else process.env["HERDR_SOCKET"] = oldSocket; - } -}); - -for (const stale of [false, true]) { - it.skipIf(process.platform !== "linux")(`reads a running GJC transcript when herdr names no agent for the pane${stale ? " and still holds an earlier Codex session report" : ""}`, async () => { - const path = join(dir, `routing-${stale ? "stale" : "unknown"}.jsonl`); - writeFileSync(path, JSON.stringify({ type: "session", cwd: home }) + "\n" + JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: "GJC owns this answer" }] } }) + "\n"); - const id = await pane([path]); - if (stale) await herdrRpc("pane.report_agent_session", { pane_id: id, source: "herdr:codex", agent: "codex", seq: Date.now(), agent_session_id: "01a0f16d-c20a-7a02-a880-e89b68155802" }); - const metadata = (await sessionSnapshot()).panes.find((p) => p.pane_id === id)!; - expect(metadata.agent ?? null).toBeNull(); - if (stale) expect(metadata.agent_session?.agent).toBe("codex"); - const oldHome = process.env["HOME"], oldSocket = process.env["HERDR_SOCKET"]; - process.env["HERDR_SOCKET"] = herdrSocketPath(); - process.env["HOME"] = home; - try { - const conversation = await paneConversation(id); - expect(conversation.source).toBe("gjc-transcript"); - expect(conversation.turns[0]!.parts[0]).toMatchObject({ kind: "text", text: "GJC owns this answer" }); - } finally { - if (oldHome === undefined) delete process.env["HOME"]; else process.env["HOME"] = oldHome; - if (oldSocket === undefined) delete process.env["HERDR_SOCKET"]; else process.env["HERDR_SOCKET"] = oldSocket; - } - }); + throw new Error("GJC stand-in screen text did not appear in the pane"); } -it("follows the agent herdr names for a pane: a gjc process in it does not take the chat", async () => { - const path = join(dir, "routing-labelled.jsonl"); - writeFileSync(path, JSON.stringify({ type: "session", cwd: home }) + "\n" + JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: "GJC owns this answer" }] } }) + "\n"); - const id = await pane([path]); - await herdrRpc("pane.report_agent", { pane_id: id, source: "manual", agent: "codex", state: "idle" }); +async function setAgent(id: string, agent: string): Promise { + await herdrRpc("pane.report_agent", { pane_id: id, source: "manual", agent, state: "idle" }); for (let attempt = 0; attempt < 100; attempt++) { - if ((await sessionSnapshot()).panes.find((p) => p.pane_id === id)?.agent === "codex") break; + if ((await sessionSnapshot()).panes.find((item) => item.pane_id === id)?.agent === agent) return; await Bun.sleep(50); } - expect((await sessionSnapshot()).panes.find((p) => p.pane_id === id)!.agent).toBe("codex"); - const oldHome = process.env["HOME"], oldSocket = process.env["HERDR_SOCKET"]; - process.env["HERDR_SOCKET"] = herdrSocketPath(); + throw new Error(`herdr did not report ${agent} for the fixture pane`); +} + +function sdkRow(pid: number, cwd: string, sessionId: string, overrides: Record = {}): Record { + return { + sessionId, + locator: { cwd, worktreeRoot: null, stateRoot: join(cwd, ".gjc", "state") }, + endpointGeneration: 1, + pid, + live: true, + terminal: false, + deleted: false, + ambiguous: false, + terminalUncertain: false, + identityProvenance: "composite", + hostIncarnation: "test-host-incarnation", + indexSeq: 1, + ...overrides, + }; +} + +function writeRegistry(rows: Record[], paths: Record = {}, kind?: string): void { + writeFileSync(registry, JSON.stringify({ rows, paths, ...(kind ? { kind } : {}) })); +} + +async function withSdkFixture(run: () => Promise): Promise { + const keys = ["HOME", "HERDR_SOCKET", "PATH", "GJC_TEST_REGISTRY", "GJC_TEST_AGENT_DIR"] as const; + const previous = new Map(keys.map((key) => [key, process.env[key]])); process.env["HOME"] = home; + process.env["HERDR_SOCKET"] = herdrSocketPath(); + process.env["PATH"] = `${bin}${process.env["PATH"] ? `:${process.env["PATH"]}` : ""}`; + process.env["GJC_TEST_REGISTRY"] = registry; + process.env["GJC_TEST_AGENT_DIR"] = agentDir; try { - // the Codex route has no rollout for this pane and says so; it is not handed to gjc - await expect(paneConversation(id)).rejects.toBeInstanceOf(ConversationUnavailable); + return await run(); } finally { - if (oldHome === undefined) delete process.env["HOME"]; else process.env["HOME"] = oldHome; - if (oldSocket === undefined) delete process.env["HERDR_SOCKET"]; else process.env["HERDR_SOCKET"] = oldSocket; + for (const key of keys) { + const value = previous.get(key); + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } } -}); +} -// binding by open file reads /proc: on macOS gjc is found by its terminal breadcrumb instead -it.skipIf(process.platform !== "linux")("binds same-cwd panes to their own files regardless of which transcript was modified last", async () => { - const a = join(dir, "a.jsonl"), b = join(dir, "b.jsonl"); - for (const path of [a, b]) writeFileSync(path, JSON.stringify({ type: "session", cwd: home }) + "\n"); - const first = await pane([a]), second = await pane([b]); - for (const newest of [a, b]) { - utimesSync(newest, new Date(), new Date(Date.now() + 60_000)); - expect(await gjcTranscriptPath(first, home, home)).toBe(a); - expect(await gjcTranscriptPath(second, home, home)).toBe(b); - } - const directoryOnly = await pane([dir]); - await expect(gjcTranscriptPath(directoryOnly, home, home)).rejects.toThrow(ConversationUnavailable); - const ambiguous = await pane([a, b]); - await expect(gjcTranscriptPath(ambiguous, home, home)).rejects.toThrow(ConversationUnavailable); - await expect(gjcTranscriptPath(first, "/different-cwd", home)).rejects.toThrow(ConversationUnavailable); -}); +function record(cwd: string, answer: string, id: string, title = "shared title"): string { + return JSON.stringify({ type: "session", cwd, id, title }) + "\n" + + JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: answer }] } }) + "\n"; +} -it.skipIf(process.platform !== "linux")("binds a pane whose session holds a subagent's file open to the session, not the subagent", async () => { - const session = join(dir, "parent.jsonl"), subagent = join(dir, "parent", "0-Worker.jsonl"); - mkdirSync(join(dir, "parent")); - for (const path of [session, subagent]) writeFileSync(path, JSON.stringify({ type: "session", cwd: home }) + "\n"); - expect(await gjcTranscriptPath(await pane([subagent]), home, home)).toBe(session); - // the session and its subagent open at once are one session, not two candidates - expect(await gjcTranscriptPath(await pane([session, subagent]), home, home)).toBe(session); -}); +async function expectConversation(id: string, answer: string): Promise { + const conversation = await paneConversation(id); + expect(conversation.source).toBe("gjc-transcript"); + expect(conversation.turns[0]!.parts[0]).toMatchObject({ kind: "text", text: answer }); +} +it("SDK identity ignores a copied neighboring answer on the pane screen", async () => withSdkFixture(async () => { + const a = join(dir, "sdk-answer-a.jsonl"), b = join(dir, "sdk-answer-b.jsonl"); + const otherAnswer = "This long neighboring response is deliberately unique so visible screen matching would choose this wrong transcript instead of the short SDK-owned answer."; + writeFileSync(a, record(home, "OK", "sdk-answer-a")); + writeFileSync(b, record(home, otherAnswer, "sdk-answer-b")); + const id = await pane([dir], otherAnswer); + await waitForScreen(id, otherAnswer.slice(0, 40)); + const { pid, argv } = await gjcProcess(id); + expect(argv).toContain(script); + expect(argv[0]).toBe(process.execPath); + writeRegistry([sdkRow(pid, home, "sdk-answer-a")], { "sdk-answer-a": a }); + + expect(heldPaths(pid)).not.toContain(a); + expect(await gjcTranscriptPath(id, home, home)).toBe(a); + await expectConversation(id, "OK"); +})); + +it("SDK maps same-cwd panes by exact GJC PID despite identical answers, titles, and mtimes", async () => withSdkFixture(async () => { + const a = join(dir, "same-answer-a.jsonl"), b = join(dir, "same-answer-b.jsonl"); + writeFileSync(a, record(home, "same", "same-answer-a")); + writeFileSync(b, record(home, "same", "same-answer-b")); + const first = await pane([dir], "same"), second = await pane([dir], "same"); + await waitForScreen(first, "same"); + await waitForScreen(second, "same"); + const firstProcess = await gjcProcess(first), secondProcess = await gjcProcess(second); + expect(firstProcess.pid).not.toBe(secondProcess.pid); + const sameTime = new Date(Date.now() - 60_000); + utimesSync(a, sameTime, sameTime); + utimesSync(b, sameTime, sameTime); + writeRegistry([ + sdkRow(firstProcess.pid, home, "same-answer-a"), + sdkRow(secondProcess.pid, home, "same-answer-b"), + ], { "same-answer-a": a, "same-answer-b": b }); -it("restores a directory-only runtime using its fresh terminal breadcrumb, without cwd recency", async () => { - const a = join(dir, "breadcrumb-a.jsonl"), b = join(dir, "breadcrumb-b.jsonl"); - for (const [path, answer] of [[a, "first answer"], [b, "second answer"]]) { - writeFileSync(path!, JSON.stringify({ type: "session", cwd: home }) + "\n" + JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: answer }] } }) + "\n"); - } - const first = await pane([dir]), second = await pane([dir]); - const marker = async (id: string, path: string) => { - const info = await herdrRpc<{ process_info: { foreground_processes: { pid: number; argv?: string[] }[] } }>("pane.process_info", { pane_id: id }); - const pid = info.process_info.foreground_processes.find(p => p.argv?.includes(script))!.pid; - const terminal = gjcTerminal(pid)!; - expect(terminal).not.toBeNull(); - const folder = join(home, ".gjc", "agent", "terminal-sessions"); - mkdirSync(folder, { recursive: true }); - const file = join(folder, terminal.id); - writeFileSync(file, `${home}\n${path}\n`); - return { file, terminal }; - }; - const one = await marker(first, a); - await marker(second, b); - utimesSync(b, new Date(), new Date(Date.now() + 60_000)); expect(await gjcTranscriptPath(first, home, home)).toBe(a); expect(await gjcTranscriptPath(second, home, home)).toBe(b); - const conversation = transcriptPage("gjc-transcript", await gjcTranscriptPath(first, home, home)); - expect(conversation.turns[0]!.parts[0]).toMatchObject({ kind: "text", text: "first answer" }); - // /new or /resume updates this terminal's pointer, even if its old file is newer. - writeFileSync(one.file, `${home}\n${b}\n`); - expect(await gjcTranscriptPath(first, home, home)).toBe(b); - utimesSync(one.file, new Date(0), new Date(one.terminal.startedAt - 60_000)); - await expect(gjcTranscriptPath(first, home, home)).rejects.toThrow(ConversationUnavailable); -}); + await expectConversation(first, "same"); + await expectConversation(second, "same"); +})); +it("SDK follows /new or resume when the same GJC PID changes from A to B without changing its one-word screen", async () => withSdkFixture(async () => { + const a = join(dir, "resume-a.jsonl"), b = join(dir, "resume-b.jsonl"); + writeFileSync(a, record(home, "A", "resume-a")); + writeFileSync(b, record(home, "B", "resume-b")); + const id = await pane([dir], "A"); + await waitForScreen(id, "A"); + const { pid } = await gjcProcess(id); + await setAgent(id, "gjc"); + writeRegistry([sdkRow(pid, home, "resume-a")], { "resume-a": a, "resume-b": b }); + expect(await gjcTranscriptPath(id, home, home)).toBe(a); -it("restores a directory-only runtime by unique visible assistant text, never newest-file order", async () => { - const answer = "This uniquely identifiable assistant answer belongs to the first pane and describes restoring its structured chat without borrowing a neighboring session."; - const other = "A completely different assistant response belongs to the neighboring pane and explains its own unrelated task with enough detail to distinguish the two sessions."; - const record = (value: string) => JSON.stringify({ type: "session", cwd: home }) + "\n" + JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: value }] } }) + "\n"; - const a = join(dir, "visible-a.jsonl"), b = join(dir, "visible-b.jsonl"); - writeFileSync(a, record(answer)); writeFileSync(b, record(other)); - const first = await pane([dir], answer), second = await pane([dir], other); - for (const [id, text] of [[first, answer], [second, other]]) { - for (let attempt = 0; attempt < 100; attempt++) { - if ((await paneRead({ paneId: id! })).text.includes(text!.slice(0, 40))) break; - await Bun.sleep(50); - } + // The fixture SDK registry now reflects /new or resume; neither the PID nor the pane screen changes. + writeRegistry([sdkRow(pid, home, "resume-b")], { "resume-a": a, "resume-b": b }); + expect((await paneRead({ paneId: id })).text).toContain("A"); + expect(await gjcTranscriptPath(id, home, home)).toBe(b); + await expectConversation(id, "B"); +})); + +it("SDK scopes discovery so unrelated retired history cannot exhaust the pane's page budget", async () => withSdkFixture(async () => { + const a = join(dir, "scoped-a.jsonl"); + writeFileSync(a, record(home, "scoped answer", "scoped-a")); + const id = await pane([dir]); + const { pid } = await gjcProcess(id); + const otherCwd = join(home, "unrelated-project"); + mkdirSync(otherCwd); + const retired = Array.from({ length: 801 }, (_, index) => + sdkRow(900_000 + index, otherCwd, `old-${index}`, { live: false })); + writeRegistry([...retired, sdkRow(pid, home, "scoped-a")], { "scoped-a": a }); + expect(await gjcTranscriptPath(id, home, home)).toBe(a); +})); + +it("SDK GJC identity overrides stale Codex session metadata", async () => withSdkFixture(async () => { + const a = join(dir, "stale-codex-a.jsonl"); + writeFileSync(a, record(home, "SDK GJC answer", "stale-codex-a")); + const id = await pane([dir], "SDK GJC answer"); + const { pid } = await gjcProcess(id); + await herdrRpc("pane.report_agent_session", { + pane_id: id, + source: "herdr:codex", + agent: "codex", + seq: Date.now(), + agent_session_id: "01a0f16d-c20a-7a02-a880-e89b68155802", + }); + const metadata = (await sessionSnapshot()).panes.find((item) => item.pane_id === id)!; + expect(metadata.agent ?? null).toBeNull(); + expect(metadata.agent_session?.agent).toBe("codex"); + writeRegistry([sdkRow(pid, home, "stale-codex-a")], { "stale-codex-a": a }); + + await expectConversation(id, "SDK GJC answer"); +})); + +async function expectSdkFailure(scenario: string): Promise { + await withSdkFixture(async () => { + const a = join(dir, `failure-${scenario}-a.jsonl`), b = join(dir, `failure-${scenario}-b.jsonl`); + const guessedAnswer = "This long screen answer would identify the neighboring transcript if screen matching were still used as a fallback."; + writeFileSync(a, record(home, "SDK target", `failure-${scenario}-a`)); + writeFileSync(b, record(home, guessedAnswer, `failure-${scenario}-b`)); + const id = await pane([dir], guessedAnswer); + await waitForScreen(id, guessedAnswer.slice(0, 40)); + const { pid } = await gjcProcess(id); + await setAgent(id, "gjc"); + const target = sdkRow(pid, home, `failure-${scenario}-a`); + const paths = { [`failure-${scenario}-a`]: a, [`failure-${scenario}-b`]: b }; + + if (scenario === "unavailable") writeRegistry([target], paths, "unavailable"); + else if (scenario === "malformed") writeRegistry([target], paths, "malformed"); + else if (scenario === "ambiguous") writeRegistry([ + target, + sdkRow(pid, home, `failure-${scenario}-b`), + ], paths); + else if (scenario === "stale") writeRegistry([sdkRow(pid, home, `failure-${scenario}-a`, { live: false })], paths); + else writeRegistry([target]); + + await expect(paneConversation(id)).rejects.toBeInstanceOf(ConversationUnavailable); + }); +} + +for (const scenario of ["unavailable", "malformed", "ambiguous", "stale", "omitted path"]) { + it(`SDK ${scenario} identity fails closed instead of guessing from the pane screen`, async () => expectSdkFailure(scenario)); +} + +it("pane routing honors an actual named non-GJC agent even when a GJC process is present", async () => withSdkFixture(async () => { + const a = join(dir, "named-non-gjc.jsonl"); + writeFileSync(a, record(home, "GJC must not take this pane", "named-non-gjc")); + const id = await pane([dir], "GJC must not take this pane"); + const { pid } = await gjcProcess(id); + await setAgent(id, "codex"); + writeRegistry([sdkRow(pid, home, "named-non-gjc")], { "named-non-gjc": a }); + + expect((await sessionSnapshot()).panes.find((item) => item.pane_id === id)?.agent).toBe("codex"); + await expect(paneConversation(id)).rejects.toBeInstanceOf(ConversationUnavailable); +})); + +it("SDK GJC resolution respects the process foreground cwd", async () => withSdkFixture(async () => { + const cwd = join(home, "foreground-project"); + mkdirSync(cwd); + const a = join(dir, "foreground-cwd.jsonl"), other = join(dir, "pane-shell-cwd.jsonl"); + writeFileSync(a, record(cwd, "foreground cwd answer", "foreground-cwd")); + writeFileSync(other, record(home, "wrong pane cwd answer", "pane-shell-cwd")); + const id = await pane([dir], "foreground cwd answer", cwd); + const { pid } = await gjcProcess(id); + await setAgent(id, "gjc"); + let metadata = (await sessionSnapshot()).panes.find((item) => item.pane_id === id)!; + for (let attempt = 0; attempt < 100 && metadata.foreground_cwd !== cwd; attempt++) { + await Bun.sleep(50); + metadata = (await sessionSnapshot()).panes.find((item) => item.pane_id === id)!; } - utimesSync(b, new Date(), new Date(Date.now() + 60_000)); - expect(await gjcTranscriptPath(first, home, home)).toBe(a); - expect(await gjcTranscriptPath(second, home, home)).toBe(b); - writeFileSync(join(dir, "duplicate.jsonl"), record(answer)); - await expect(gjcTranscriptPath(first, home, home)).rejects.toThrow(ConversationUnavailable); -}); + expect(metadata.cwd).toBe(home); + expect(metadata.foreground_cwd).toBe(cwd); + writeRegistry([sdkRow(pid, cwd, "foreground-cwd")], { "foreground-cwd": a }); -it("starts the gjc this server found on its PATH, by absolute path, and waits until it runs in the pane", async () => { + await expectConversation(id, "foreground cwd answer"); +})); + +it("starts the gjc this server found on its PATH, by absolute path, and waits until it runs in the pane", async () => withSdkFixture(async () => { const shell = async () => { const created = await workspaceCreate({ cwd: home, label: "herdr-web-ui-test-gjc-start" }); workspaces.push(created.workspace.workspace_id); return created.root_pane.pane_id; }; - // a `gjc` executable in a directory with a space, on this process's PATH only: the pane shell never sees that PATH - const bin = join(home, "agent bin"); - mkdirSync(bin); - const exe = join(bin, "gjc"); - writeFileSync(exe, `#!/bin/sh\nexec ${process.execPath} ${script} "$@"\n`, { mode: 0o755 }); - const path = process.env["PATH"]; + // A `gjc` executable in a directory with a space is on this process's PATH only; the pane shell never sees that PATH. + const spacedBin = join(home, "agent bin"); + mkdirSync(spacedBin); + const exe = join(spacedBin, "gjc"); + writeFileSync(exe, `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(script)} "$@"\n`, { mode: 0o755 }); + const previousPath = process.env["PATH"]; try { - process.env["PATH"] = `${bin}:${path}`; + process.env["PATH"] = `${spacedBin}:${previousPath ?? ""}`; const id = await shell(); await startShellAgent("gjc", id, [dir]); const info = await herdrRpc<{ process_info?: { foreground_processes?: { argv?: string[] }[] } }>("pane.process_info", { pane_id: id }); - expect(info.process_info?.foreground_processes?.find((process) => isGjcProcess(process.argv ?? []))?.argv?.slice(-1)).toEqual([dir]); + expect(info.process_info?.foreground_processes?.find((candidate) => isGjcProcess(candidate.argv ?? []))?.argv?.slice(-1)).toEqual([dir]); process.env["PATH"] = "/nonexistent"; const bare = await shell(); await expect(startShellAgent("gjc", bare, [], { timeoutMs: 1500 })).rejects.toThrow("not on this server's PATH"); expect((await paneRead({ paneId: bare })).text.replaceAll(home, "")).not.toContain("gjc"); - } finally { process.env["PATH"] = path; } -}); + } finally { + if (previousPath === undefined) delete process.env["PATH"]; else process.env["PATH"] = previousPath; + } +})); diff --git a/server/grok-store.ts b/server/grok-store.ts index e6442eef4..c083b1fdb 100644 --- a/server/grok-store.ts +++ b/server/grok-store.ts @@ -2,7 +2,7 @@ import { opendirSync, readFileSync, readlinkSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; import type { HerdrPane } from "../shared/protocol.ts"; -import { herdrRpc } from "./herdr/client.ts"; +import { paneProcessInfo } from "./herdr/client.ts"; import { runsAgent } from "./gjc-runtime.ts"; const SESSION = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; @@ -10,7 +10,7 @@ const MAX_GROUPS = 4096; /** The one native build whose held-file evidence was observed. Others have no reader yet. */ const GROK_NATIVE_BUILD = "grok-1.0.50-linux-x86_64"; -export interface GrokProcessInfo { foreground_processes?: { pid: number; argv?: string[] }[] } +export interface GrokProcessInfo { foreground_processes?: { pid: number; argv?: string[] | null }[] } const GROK_EXECUTABLE = /(^|[\\/])grok(?:-\d[^\\/]*)?(?:\.exe|\.[cm]?js)?$/i; export function grokPaneProcesses(info: GrokProcessInfo): { pid: number }[] { return (info.foreground_processes ?? []).filter((p) => runsAgent(p.argv ?? [], GROK_EXECUTABLE)); @@ -91,8 +91,7 @@ export async function resolveGrokStore(pane: HerdrPane, configuredHome?: string) // Every other build and PC gets the terminal view; argv alone does not attest the version. if (process.platform !== "linux") return null; try { - const info = await herdrRpc<{ process_info?: GrokProcessInfo }>("pane.process_info", { pane_id: pane.pane_id }); - const processes = grokPaneProcesses(info.process_info ?? {}); + const processes = grokPaneProcesses((await paneProcessInfo(pane.pane_id)) ?? {}); if (processes.length !== 1) return null; const owner = processes[0]!; if (basename(readlinkSync(`/proc/${owner.pid}/exe`)) !== GROK_NATIVE_BUILD) return null; diff --git a/server/herdr/client.ts b/server/herdr/client.ts index 3afa7cf4b..53ff80ae3 100644 --- a/server/herdr/client.ts +++ b/server/herdr/client.ts @@ -11,7 +11,8 @@ import type { PaneFindResponse, PaneFindMatch, } from "../../shared/protocol.ts"; -import type { AgentInfo, AgentManifestInfo, AgentStartParams, PaneInfo, PaneLayoutSnapshot, PaneMoveParams, PaneMoveResult, PaneScrollInfo, TabInfo, WorkspaceInfo } from "../../shared/herdr-api.generated.ts"; +import type { AgentInfo, AgentManifestInfo, AgentStartParams, PaneInfo, PaneLayoutSnapshot, PaneProcessInfo, PaneScrollInfo, TabInfo, WorkspaceInfo } from "../../shared/herdr-api.generated.ts"; +import type { HerdrMethod, HerdrMethods, SingleHerdrMethod, UntypedHerdrMethod } from "./methods.ts"; import type { HerdrIdentity } from "../../shared/machines.ts"; const DEFAULT_TIMEOUT_MS = 10_000; @@ -81,15 +82,38 @@ function makeLineReader(onLine: (line: string) => void): (chunk: Uint8Array) => * connect is awaited, and a caller's right to send can lapse meanwhile. A guard that * answers false sends nothing and rejects with `cancelled`. */ -export async function herdrRpc( - method: string, +export function herdrRpc( + method: SingleHerdrMethod, + params: HerdrMethods[M]["params"], + socketPath?: string, + timeoutMs?: number, + guard?: () => boolean, +): Promise; +/** + * Untyped fallback for a method `HerdrMethods` does not list yet. Outside server/herdr/ only + * tests, scripts and the files in HERDR_RPC_ALLOWED (server/conventions.test.ts) may call it; + * server code adds a typed wrapper here instead. It rejects a typed method only when `M` is + * inferred from a literal: with an explicit result type (`herdrRpc("pane.process_info", …)`) + * or a method typed as plain `string`, `M` is `string` and any params compile. Closing that + * needs this fallback under its own name, left to the follow-up PR that moves the + * HERDR_RPC_ALLOWED files to the wrappers once their open PRs land. + */ +export function herdrRpc( + method: UntypedHerdrMethod, params: Record, + socketPath?: string, + timeoutMs?: number, + guard?: () => boolean, +): Promise; +export async function herdrRpc( + method: string, + params: object, socketPath: string = herdrSocketPath(), timeoutMs: number = DEFAULT_TIMEOUT_MS, guard?: () => boolean, -): Promise { +): Promise { const id = nextId(); - return await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { let settled = false; let socket: { end: () => void } | null = null; let timer: ReturnType | null = null; @@ -124,7 +148,7 @@ export async function herdrRpc( finish(() => reject(new HerdrError(code, message))); return; } - finish(() => resolve(frame.result as T)); + finish(() => resolve(frame.result)); }; const onData = makeLineReader(handleLine); @@ -542,6 +566,22 @@ export async function paneSelectionRead(paneId: string, anchor: PaneTextPoint, c return result.text; } +/** What herdr knows of the processes in a pane's terminal (`pane.process_info`). */ +export type ProcessInfo = PaneProcessInfo; + +/** + * The pane's shell and foreground processes, or undefined when herdr names none. Always by + * `pane_id`: given `target`, herdr silently answers for the focused pane. Rejects as herdrRpc does. + */ +export async function paneProcessInfo(paneId: string, socketPath?: string): Promise { + return (await herdrRpc("pane.process_info", { pane_id: paneId }, socketPath)).process_info; +} + +/** The agent herdr tracks in a pane (`agent.get`), including the session it reported. */ +export async function agentGet(paneId: string, socketPath?: string): Promise { + return (await herdrRpc("agent.get", { target: paneId }, socketPath)).agent; +} + export async function paneSendText(paneId: string, text: string, socketPath?: string, guard?: () => boolean): Promise { await herdrRpc("pane.send_text", { pane_id: paneId, text }, socketPath, undefined, guard); } @@ -568,17 +608,6 @@ export async function paneFocus(paneId: string, socketPath?: string): Promise { - const answer = await herdrRpc<{ move_result: PaneMoveResult }>("pane.move", { ...params } satisfies PaneMoveParams, socketPath); - return answer.move_result; -} - /** * A new pane beside `paneId` (herdr's prefix+v / prefix+-), answered as herdr's snapshot lists it. * herdr's focus stays where it is unless `focus` asks for the new pane, as its --focus does. diff --git a/server/herdr/methods.ts b/server/herdr/methods.ts new file mode 100644 index 000000000..b201cee47 --- /dev/null +++ b/server/herdr/methods.ts @@ -0,0 +1,38 @@ +import type { AgentInfo, AgentTarget, PaneProcessInfo, PaneProcessInfoParams, PaneSendKeysParams, PaneSendTextParams } from "../../shared/herdr-api.generated.ts"; + +/** + * The herdr methods `herdrRpc` knows the shape of: each method's params and its raw answer + * (the envelope; the wrappers in client.ts unwrap it). Add a method here, with a wrapper in + * client.ts, before calling it from outside server/herdr/. + * + * Hand-written on purpose: herdr's schema maps a method to its params (`request.oneOf`), but its + * answers are one `ResponseResult.oneOf` told apart by a `type` tag (`pane_process_info`, + * `agent_info`, `ok`) with no link back to the method, so a generated method -> result table + * would be a hand-kept table anyway. The params and result bodies come from the generated types + * (scripts/generate-protocol-types.ts ROOTS). + */ +export interface HerdrMethods { + /** `pane_id`, never `target`: given `target`, herdr silently answers for the focused pane. */ + "pane.process_info": { params: PaneProcessInfoParams & { pane_id: string }; result: { type?: "pane_process_info"; process_info?: PaneProcessInfo } }; + "agent.get": { params: AgentTarget; result: { type?: "agent_info"; agent: AgentInfo } }; + "pane.send_text": { params: PaneSendTextParams; result: { type?: "ok" } }; + "pane.send_keys": { params: PaneSendKeysParams; result: { type?: "ok" } }; +} + +export type HerdrMethod = keyof HerdrMethods; + +/** + * One typed method, never a union of them: given `"pane.process_info" | "agent.get"`, the params + * would be the union of both methods' params, and `{ target }` would pass for pane.process_info. + */ +export type SingleHerdrMethod = IsUnion extends true ? never : M; +type IsUnion = T extends unknown ? ([U] extends [T] ? false : true) : never; + +/** + * A method `HerdrMethods` does not type yet. A typed method given here is `never`, so a call + * like `herdrRpc("pane.process_info", { target })` fails to compile instead of falling back. + * That holds only while `M` is inferred from the literal: with an explicit result type + * (`herdrRpc("pane.process_info", …)`) or a method typed as plain `string`, `M` is `string` + * and the fallback takes any params (see the herdrRpc overloads in client.ts). + */ +export type UntypedHerdrMethod = M extends HerdrMethod ? never : M; diff --git a/server/herdr/seam.test.ts b/server/herdr/seam.test.ts new file mode 100644 index 000000000..374081be3 --- /dev/null +++ b/server/herdr/seam.test.ts @@ -0,0 +1,75 @@ +import { afterEach, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { agentGet, herdrRpc, paneProcessInfo } from "./client.ts"; + +// The typed herdr seam (server/herdr/methods.ts). `bun run typecheck` checks the lines marked +// with ts-expect-error below: each must stay a compile error. They hold for a call without an explicit +// result type; `herdrRpc(…)` still reaches the untyped fallback, which the files in +// HERDR_RPC_ALLOWED (server/conventions.test.ts) use until a follow-up PR moves them to the +// wrappers once their open PRs land, so outside server/herdr/ that guard, not the compiler, +// keeps server code off herdrRpc. +declare const either: boolean; +async function typeOnly(): Promise { + // @ts-expect-error pane.process_info takes pane_id: given target, herdr answers for the focused pane + await herdrRpc("pane.process_info", { target: "pane-1" }); + // @ts-expect-error agent.get takes target + await herdrRpc("agent.get", { pane_id: "pane-1" }); + // @ts-expect-error pane.send_text needs its text + await herdrRpc("pane.send_text", { pane_id: "pane-1" }); + // @ts-expect-error a union of typed methods would take either method's params + await herdrRpc(either ? ("pane.process_info" as const) : ("agent.get" as const), { target: "pane-1" }); + // Known holes: these still compile, because an explicit result type or a plain-string method + // leaves the fallback's `M` at `string`. Splitting the fallback out under its own name (the + // follow-up above) turns both into ts-expect-error lines. + await herdrRpc("pane.process_info", { target: "pane-1" }); + const widened: string = "pane.process_info"; + await herdrRpc(widened, { target: "pane-1" }); + const info = await herdrRpc("pane.process_info", { pane_id: "pane-1" }); + const shell: number | null | undefined = info.process_info?.shell_pid; + const agent = await agentGet("pane-1"); + const kind: string | undefined = agent.agent_session?.kind; + void shell; void kind; +} +void typeOnly; + +// A stand-in herdr on a Unix socket: it records each request and answers with `result`. +let dir = ""; +let listener: ReturnType | null = null; +afterEach(() => { + listener?.stop(true); + listener = null; + if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +function standIn(result: unknown): { path: string; written: { method: string; params: unknown }[] } { + dir = mkdtempSync(join(tmpdir(), "herdr-seam-")); + const path = join(dir, "herdr.sock"); + const written: { method: string; params: unknown }[] = []; + listener = Bun.listen({ + unix: path, + socket: { + data(sock, chunk) { + const request = JSON.parse(chunk.toString()) as { id: string; method: string; params: unknown }; + written.push({ method: request.method, params: request.params }); + sock.write(`${JSON.stringify({ id: request.id, result })}\n`); + }, + }, + }); + return { path, written }; +} + +it("asks pane.process_info by pane_id and answers its process_info", async () => { + const process_info = { pane_id: "pane-1", shell_pid: 41, foreground_processes: [{ pid: 42, name: "codex", argv: ["codex"] }] }; + const { path, written } = standIn({ type: "pane_process_info", process_info }); + expect(await paneProcessInfo("pane-1", path)).toEqual(process_info); + expect(written).toEqual([{ method: "pane.process_info", params: { pane_id: "pane-1" } }]); +}); + +it("asks agent.get by target and answers its agent", async () => { + const agent = { pane_id: "pane-1", agent_session: { agent: "pi", kind: "path", source: "herdr:pi", value: "/s.jsonl" } }; + const { path, written } = standIn({ type: "agent_info", agent }); + expect(await agentGet("pane-1", path)).toEqual(agent as never); + expect(written).toEqual([{ method: "agent.get", params: { target: "pane-1" } }]); +}); diff --git a/server/hermes.test.ts b/server/hermes.test.ts index 9cd74cfef..77dd9f808 100644 --- a/server/hermes.test.ts +++ b/server/hermes.test.ts @@ -118,6 +118,18 @@ describe("hermes message rows parsing", () => { expect(toolSummary("write_file", { code: "let x = 1;", path: "test.js" })).toBe("test.js"); }); + it("sums a call up by what the agent said it is for, and only where that field is a purpose", () => { + // OmO's eval carries its own one-line summary beside the code + expect(toolSummary("eval", { language: "js", code: "await check()", summary: " Run the checks " })).toBe("Run the checks"); + // Claude describes a Bash command and a subagent's prompt + expect(toolSummary("Bash", { command: "bun test", description: "Run the unit tests" })).toBe("Run the unit tests"); + expect(toolSummary("Agent", { prompt: "Find the callers", description: "Find callers" })).toBe("Find callers"); + expect(toolSummary("eval", { code: "1", summary: "x".repeat(200) })).toHaveLength(120); + // a blank purpose falls back to the command; a summary without code is another tool's data + expect(toolSummary("Bash", { command: "bun test", description: " " })).toBe("bun test"); + expect(toolSummary("mcp__notes__add", { summary: "Weekly notes", path: "notes.md" })).toBe("notes.md"); + }); + it("decodes terminal tool envelopes, marks nonzero exits and errors failed, and preserves invalid/non-terminal JSON", () => { const rows: HermesMessageRow[] = [ { diff --git a/server/http.test.ts b/server/http.test.ts index 716cb74cb..43b5a6c7c 100644 --- a/server/http.test.ts +++ b/server/http.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, spyOn } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as herdr from "./herdr/client.ts"; import { HerdrError } from "./herdr/client.ts"; import { errorResponse } from "./http.ts"; +import { createServer } from "./index.ts"; describe("errorResponse", () => { it("answers a 500 with a fixed message and a correlation id, never the exception's own", async () => { @@ -25,4 +30,73 @@ describe("errorResponse", () => { expect(connect.error).toEqual({ code: "connect_failed", message: "herdr is not answering" }); expect(errorResponse(new HerdrError("pane_not_found", "no such pane")).status).toBe(404); }); -}); \ No newline at end of file +}); + +describe("pane find HTTP validation", () => { + const request = { pane_id: "w1:p1", query: "needle", direction: "forward" }; + const previous = { start: { row: 0, col: 0 }, end: { row: 0xffffffff, col: 0xffff } }; + let server: ReturnType; + let stateDir: string; + const find = spyOn(herdr, "paneFind").mockImplementation(async () => { + throw new HerdrError("stale_content", "search content changed"); + }); + const post = (patch: Record) => fetch(`http://127.0.0.1:${server.port}/api/pane/find`, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...request, ...patch }), + }); + beforeAll(() => { + stateDir = mkdtempSync(join(tmpdir(), "herdr-find-http-")); + server = createServer({ port: 0, hostname: "127.0.0.1", stateDir, token: "", machines: false, sidecar: false }); + }); + afterEach(() => find.mockClear()); + afterAll(() => { + server.stop(); + find.mockRestore(); + rmSync(stateDir, { recursive: true, force: true }); + }); + + for (const patch of [ + {}, { jump: false }, { jump: true }, { content_revision: 0 }, + { previous, content_revision: Number.MAX_SAFE_INTEGER, jump: false }, + { previous: { ...previous, extra: "discard" }, content_revision: 0 }, + ]) { + it(`forwards validated optional fields for ${JSON.stringify(patch)}`, async () => { + // given + const expected = { ...request, ...patch, ...("previous" in patch ? { previous } : {}) }; + // when + const response = await post(patch); + // then + expect(find).toHaveBeenCalledTimes(1); + expect(find).toHaveBeenCalledWith(expected); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: { code: "stale_content", message: "search content changed" } }); + }); + } + + for (const [patch, code] of [ + [{ jump: null }, "invalid_jump"], [{ jump: 0 }, "invalid_jump"], [{ jump: "false" }, "invalid_jump"], + [{ content_revision: null }, "invalid_revision"], [{ content_revision: -1 }, "invalid_revision"], + [{ content_revision: 0.5 }, "invalid_revision"], [{ content_revision: Number.MAX_SAFE_INTEGER + 1 }, "invalid_revision"], + [{ previous: null, content_revision: 0 }, "invalid_previous"], + [{ previous: [], content_revision: 0 }, "invalid_previous"], + [{ previous: {}, content_revision: 0 }, "invalid_previous"], + [{ previous }, "invalid_previous"], [{ previous, content_revision: "0" }, "invalid_previous"], + ...[ + { row: -1, col: 0 }, { row: 0.5, col: 0 }, { row: 0x100000000, col: 0 }, + { row: 0, col: -1 }, { row: 0, col: 0.5 }, { row: 0, col: 0x10000 }, + { row: "0", col: 0 }, { row: 0, col: null }, + ].flatMap((point): [Record, string][] => [ + [{ previous: { ...previous, start: point }, content_revision: 0 }, "invalid_previous"], + [{ previous: { ...previous, end: point }, content_revision: 0 }, "invalid_previous"], + ]), + ] satisfies [Record, string][]) { + it(`rejects ${JSON.stringify(patch)} before calling herdr`, async () => { + // given: malformed optional navigation state + // when + const response = await post(patch); + // then + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code } }); + expect(find).not.toHaveBeenCalled(); + }); + } +}); diff --git a/server/index.ts b/server/index.ts index 0b8feb16d..c6c81717b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,18 +5,17 @@ import { homedir } from "node:os"; import { join, resolve, isAbsolute } from "node:path"; import type { ServerWebSocket } from "bun"; -import type { AgentKind, AgentRenameRequest, AgentStatus, ClientMessage, ClientRole, HealthAuth, HerdrPane, IntegrationsResponse, PaneDirection, PaneMoved, PanePlan, PaneResized, PaneSplit, PaneSwapped, PaneZoomed, PendingMessage, PlanSummary, PluginActionsResponse, ReadFormat, ReadSource, ServerFeature, ServerMessage, SessionSnapshot, TabMoved } from "../shared/protocol.ts"; +import type { AgentKind, AgentRenameRequest, AgentStatus, ClientMessage, ClientRole, HealthAuth, HerdrPane, IntegrationsResponse, PaneDirection, PanePlan, PaneResized, PaneSplit, PaneSwapped, PaneZoomed, PendingMessage, PlanSummary, PluginActionsResponse, ReadFormat, ReadSource, ServerFeature, ServerMessage, SessionSnapshot, TabMoved } from "../shared/protocol.ts"; import { alertStatus, paneTitle } from "../shared/notify-policy.ts"; import { DEFAULT_PORT } from "../shared/protocol.ts"; import { DEVICE_COOKIE, authClient, handleAuthRequest, isAuthenticated, parseCookies, presentedToken, presentedTokenHeld, recordPresentedTokenFailure, requiresAuth, unauthorizedJson } from "./auth.ts"; import { cameThroughProxy, decideAccess, isLoopbackAddress, isLoopbackBind, isServeOwnerRequest, lanExposed } from "./access.ts"; -import { DeviceStore, handleDeviceRequest } from "./devices.ts"; +import { DeviceStore, handleDeviceRequest, type DeviceMatch } from "./devices.ts"; import { handlePreferencesRequest, PreferenceStore } from "./preferences.ts"; import { remoteAccess, TailnetIdentitySource } from "./tailscale.ts"; import { paneCommands } from "./commands.ts"; import { paneFiles } from "./files.ts"; import { badRequest, conflictResponse, errorResponse, isCount, isJsonObject, jsonResponse } from "./http.ts"; -import { parseMoveRequest } from "./pane-move.ts"; import { serveStatic } from "./static.ts"; import { compressResponse } from "./compress.ts"; import { isMouseReport, sameAttachment } from "./input-guard.ts"; @@ -26,6 +25,7 @@ import { OPENCODE_TOOL_REF } from "./opencode.ts"; import { DevinHistoryChanged } from "./devin.ts"; import { ConversationMonitor } from "./conversation-monitor.ts"; import { omoPanes } from "./omo.ts"; +import { createGjcStatus, gjcPanes, readOmoSnapshot, reconcileProviderStatus } from "./gjc-status.ts"; import { OMO_ALIASES, OmoStatus, processAlive } from "./omo-status.ts"; import { omoRuns, omoTasks } from "./omo-tasks.ts"; import { ClaudeSubagentStatus, claudeSubagents, within } from "./claude-subagents.ts"; @@ -34,9 +34,11 @@ import { SessionPlans } from "./session-plan.ts"; import { codexOpenRollout, paneCodexHome } from "./codex.ts"; import { CompletionTracker, type OmoRest } from "./completion.ts"; import { freeAgentName } from "./agent-name.ts"; +import { launchedAgents } from "./launched-agents.ts"; import { SHELL_AGENTS, isShellAgentKind, shellAgentExecutable, startShellAgent } from "./shell-agent.ts"; import { listDirectories } from "./directories.ts"; import { fileResponse, locateFile, crossSiteFileRead } from "./file-view.ts"; +import { repoDiffResponse } from "./repo-diff.ts"; import { agentManifests, agentPrompt, @@ -50,7 +52,7 @@ import { paneClose, paneFocus, paneGet, - paneMove, + paneProcessInfo, paneRead, paneResize, paneScroll, @@ -85,19 +87,20 @@ import { worktreeRemove, } from "./herdr/client.ts"; import { type AlertTiming, createPushService, defaultStateDir, handlePushRequest } from "./push.ts"; -import { CLAUDE_CLEAR_INPUT_KEYS, claudeClearableDraft, claudeHeldIsGrey, claudeInputDraft, claudeInputEmpty, viewportShowsLive, codexQuestionsCollapsed, handlePromptRequest, isClaudeHeld, heldCandidate, modelListWaits, noteSubmitted, parseInteractivePrompt, promptWaitEnded, claudeRestoredDraft, lastChatSubmitted } from "./prompt.ts"; +import { CLAUDE_CLEAR_INPUT_KEYS, claudeClearableDraft, claudeHeldIsGrey, claudeInputBoxContent, claudeInputDraft, shownInputBox, claudeInputEmpty, viewportShowsLive, codexQuestionsCollapsed, handlePromptRequest, isClaudeHeld, heldCandidate, modelListWaits, noteSubmitted, parseInteractivePrompt, promptWaitEnded, claudeRestoredDraft, lastChatSubmitted } from "./prompt.ts"; import { secretPrompt, validSecret } from "../shared/secret-prompt.ts"; -import { PasteImageError, savePaneImage } from "./paste.ts"; +import { PasteImageError, readImageBody, savePaneImage } from "./paste.ts"; import { PtySession } from "./pty/session.ts"; import { PaneWatch } from "./watch.ts"; import { AttachOutputTail, isTakeoverExit } from "./attach-output.ts"; import { attachableIdentity, sidecarAvailable } from "./pty/sidecar.ts"; import { MirrorSession } from "./mirror.ts"; import { mirrorInput } from "./mirror-input.ts"; -import { PendingInputs, PendingInputError, type PendingIdentity, type PendingRecord } from "./pending-input.ts"; +import { PendingInputs, PendingInputError, samePendingIdentity, type PendingIdentity, type PendingRecord } from "./pending-input.ts"; +import { processStartedAt } from "./process-start.ts"; import { PendingRequestBook } from "./pending-requests.ts"; import { interruptCodexSteering } from "./codex-steering.ts"; -import { OutputWindow, OUTPUT_HIGH_BYTES, OUTPUT_HARD_BYTES, OUTPUT_STALL_MS, ReplayBuffer } from "./output-window.ts"; +import { OutputWindow, OUTPUT_HIGH_BYTES, OUTPUT_HARD_BYTES, OUTPUT_STALL_MS, ReplayBuffer, wireBytes } from "./output-window.ts"; import { OUTPUT_STALLED_CLOSE_CODE } from "../shared/terminal-flow.ts"; import { connectUpdater, handleUpdateRequest, type UpdateService } from "./update-api.ts"; import { handleHerdrUpdateRequest, HerdrUpdater } from "./herdr-update.ts"; @@ -107,6 +110,7 @@ import { handleUsageRequest, UsageService } from "./usage.ts"; import { handleVoiceRequest, VoiceService } from "./voice.ts"; import { BRIDGE_PROTOCOL } from "../shared/machines.ts"; +import { MAX_LABEL_CHARS } from "../shared/labels.ts"; import { bridgeIdentity, registerBridge } from "./bridge.ts"; import { bridgeAgentNews, MachineManager } from "./machines.ts"; import { handleMachineRequest } from "./machine-api.ts"; @@ -114,12 +118,11 @@ import { MachineRelay } from "./machine-relay.ts"; import { sameOrigin } from "./machine-security.ts"; import { PLUGIN_LOG_LIMIT, pluginActionResult, pluginLogEntry, pluginPaneContext, waitForPluginAction } from "./plugin-actions.ts"; -const MAX_REPLAY_BYTES = 256 * 1024; +const MAX_REPLAY_BYTES = 128 * 1024; /** How herdr's plugin manifests name the platform this server (and so its herdr) runs on. */ const PLUGIN_PLATFORM = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux"; /** How long POST /api/plugin/action waits for the command herdr started to end before it answers `running`. */ const PLUGIN_ACTION_WAIT_MS = 5_000; -/** How far back in herdr's plugin command log a run is looked for. */ const livePaneIds = async (timeoutMs?: number): Promise => (await sessionSnapshot(undefined, timeoutMs)).panes.map((pane) => pane.pane_id); /** * herdr's refusal of an attach while a read of the same terminal is in progress; it asks @@ -141,13 +144,14 @@ const ATTACH_RETRY_MAX_MS = 500; /** a refused attach says so within milliseconds of its first bytes: those are held this long */ const ATTACH_HOLD_MS = 100; /** - * What `herdr terminal attach` writes itself before herdr has answered: terminal modes set and - * reset only (mouse reporting off, the alternate screen on; the last one maybe cut short). The - * answer can come any time after them, later than the hold on a busy PC, so they say nothing - * about whether the attach took and do not start the hold. A refusal and a screen both begin - * with something else. + * What `herdr terminal attach` writes itself before herdr has answered, and as the teardown of a + * refused one: terminal modes set and reset (mouse reporting off, the alternate screen on, then + * off) and the cursor style restored (`ESC [ 0 SP q`), the last one maybe cut short. The answer + * can come any time after them, later than the hold on a busy PC, and herdr writes its refusal + * apart from the teardown before it, so they say nothing about whether the attach took and do + * not start the hold. A refusal and a screen both begin with something else. */ -const ATTACH_PREAMBLE_RE = /^(?:\x1b\[\?[\d;]+[hl])*(?:\x1b(?:\[(?:\?[\d;]*)?)?)?$/; +const ATTACH_PREAMBLE_RE = /^(?:\x1b\[(?:\?[\d;]+[hl]|\d* q))*(?:\x1b(?:\[(?:\?[\d;]*|\d+ ?| )?)?)?$/; /** how often a terminal another web bridge holds is tried again, while clients here still want it */ const ATTACH_HELD_RETRY_MS = 3_000; /** @@ -200,10 +204,14 @@ const CLAUDE_INPUT_DRAFT_MESSAGE = "Claude Code's input box is not empty (a draf const DRAFT_CLEAR_WAIT_MS = 1_500; const DRAFT_CLEAR_POLL_MS = 50; -/** input_draft, naming the one-row draft the chat may offer to clear (claudeClearableDraft), or none. */ +/** + * input_draft, naming the one-row draft the chat may offer to clear (claudeClearableDraft), or none. + * `box`: the input box as read, whole (claudeInputBoxContent; the client is shown it cut), which a refusal naming no draft shows so the + * user may send anyway; null where the refusal offers nothing (after a clear began). + */ class InputDraftError extends HerdrError { readonly draft: string | null; - constructor(draft: string | null, readonly identity?: PendingIdentity) { + constructor(draft: string | null, readonly identity?: PendingIdentity, readonly box: string | null = null) { super("input_draft", CLAUDE_INPUT_DRAFT_MESSAGE); this.draft = draft; } @@ -214,7 +222,7 @@ const RESTORED_CLEAR_GAP_MS = 200; const RESTORED_CLEAR_SETTLE_MS = 300; /** what a send must still have of its deadline before the copy is cleared: the clear, the box read again, then the send */ const RESTORED_CLEAR_RESERVE_MS = 2_000; -const SERVER_FEATURES: ServerFeature[] = ["submit", "pending-input", "pending-hold", "secret-input", "input-ready", "take-over", "watch", "conversation-watch"]; +const SERVER_FEATURES: ServerFeature[] = ["submit", "pending-input", "pending-hold", "secret-input", "input-ready", "take-over", "watch", "conversation-watch", "ping"]; /** the sides herdr's pane.swap and pane.resize take; anything else is refused before the RPC, which would answer invalid_request */ const isPaneDirection = (value: unknown): value is PaneDirection => value === "left" || value === "right" || value === "up" || value === "down"; @@ -302,6 +310,7 @@ async function agentLaunch(paneId: string, agent: AgentPayload): Promise<{ agent } } } + launchedAgents.launched(paneId, kind); return { agent_started: true }; } catch (error) { return { @@ -321,7 +330,7 @@ interface SocketData { unwatchDevice?: () => void; relay?: MachineRelay; /** A fresh claim per attach lifetime; deleting it invalidates queued terminal keys. */ - attached: Map; + attached: Map; conversationInterests: Set; watches: Map; output: Map; @@ -346,6 +355,14 @@ function presenceOf(raw: string | Buffer): boolean | null { type Client = ServerWebSocket; +/** + * What Bun.serve admits before a route runs (64 MiB). The ~55.9 MB body of a 40 MiB file is under it, + * so the attachment route answers it with its JSON reason. The browser refuses a file over + * MAX_ATTACHMENT_BYTES before it uploads (src/lib/api.ts), so only a non-browser client that sends + * more than this can still see Bun's own empty 413. + */ +const BUN_BODY_CEILING = 64 * 1024 * 1024; + /** * One live PTY per pane, shared by every client watching that pane. * @@ -362,8 +379,13 @@ interface PaneAttachment { /** the pty's current grid: interact clients set it, observe clients adopt it */ cols: number; rows: number; + /** each interact client's own grid while it shows the terminal, the latest last (`release-size` hands the pty to it) */ + grids: Map; + /** the client whose grid the pty took last: only its release may hand the pty on */ + driver?: Client; /** bounded tail so a client joining late still sees the current screen */ replay: ReplayBuffer; + /** when each client without ACKs (no OutputWindow) became blocked on its transport queue */ stalled: Map; /** another web bridge holds herdr's one attach slot for this terminal: waiting for it to let go */ held?: boolean; @@ -501,7 +523,8 @@ export function createServer( const pendingDrains = new Set(); type SubmitReply = { ok: boolean; pending?: PendingMessage; code?: string; message?: string }; const pendingRequests = new WeakMap>(); - const draftConfirmations = new WeakMap>(); + // the last input_draft refusal per pane: its named draft (Clear it and send), or none (Send anyway) + const draftConfirmations = new WeakMap>(); let stopped = false; const conversations = new ConversationMonitor((paneId, signature) => { for (const client of clients) { @@ -555,6 +578,17 @@ export function createServer( return run; } + /** + * Waits until input sent through herdr's RPC can no longer overtake the pane's last pty keystroke + * still on its way through the sidecar and the attach: KEY_SETTLE_MS after it, or TYPED_SETTLE_MS + * when it ended in an ESC herdr may still be holding. + */ + async function settleAfterTyping(paneId: string): Promise { + const settle = typedEscape.has(paneId) ? TYPED_SETTLE_MS : KEY_SETTLE_MS; + const typed = Date.now() - (lastTyped.get(paneId) ?? 0); + if (typed < settle) await Bun.sleep(settle - typed); + } + /** * Types a composer message and submits it, with its own Enter after the text: arriving * in the same chunk as the paste, a TUI still busy with it (turning an image path into @@ -586,9 +620,12 @@ export function createServer( * `clearDraft`: the draft an input_draft refusal named, which the user asked to clear and send * over. It goes only while the box still holds exactly that, and the message only once the box * reads empty. + * `sendAnyway`: the user saw the box a refusal naming no draft showed and chose to send: only into + * the Claude terminal and session that refusal was for (`confirmation` with no draft), and only while + * the box still shows what they saw or reads empty. Never typed instead of agent.prompt. */ async function submitText(paneId: string, text: string, payload: string, arrivedAt: number, fromTerminal = false, authorize: () => void = () => {}, - clearDraft?: string, confirmation?: { draft: string; identity: PendingIdentity }): Promise { + clearDraft?: string, confirmation?: { draft: string | null; identity: PendingIdentity; box?: string | null }, sendAnyway = false): Promise { let clearedDraft = false; const inTime = (reserve = 0): void => { authorize(); @@ -616,12 +653,30 @@ export function createServer( if (clearDraft !== undefined && !sameClaude(identity)) { throw new HerdrError("pending_target_changed", "The named draft's Claude terminal or session changed; nothing was cleared or sent"); } - if ((pane?.agent ?? pane?.agent_session?.agent) === "claude") { + if (sendAnyway && !(identity?.agent === "claude" && confirmation !== undefined && confirmation.draft === null + && identity.terminalId === confirmation.identity.terminalId && identity.session === confirmation.identity.session)) { + throw new HerdrError("pending_target_changed", "The refused message's Claude terminal or session changed, or nothing was refused; nothing was sent"); + } + if (sendAnyway) { + // only over the box the user was shown: anything typed there since is refused again, and shown + const reads = await claudeBoxReads(paneId); + inTime(); + const box = claudeInputBoxContent(reads[0]); + // a box that cannot be read whole cannot be compared: it is refused, and offers no send anyway + if (claudeInputDraft(...reads) && (box === null || box !== confirmation!.box)) throw new InputDraftError(claudeClearableDraft(...reads), identity ?? undefined, box); + // the reads waited on herdr: the pane may hold another Claude by now, checked again right before the write + const now = pendingIdentity(await paneGet(paneId)); + inTime(); + if (now.agent !== "claude" || now.terminalId !== confirmation!.identity.terminalId || now.session !== confirmation!.identity.session) { + throw new HerdrError("pending_target_changed", "The refused message's Claude terminal or session changed; nothing was sent"); + } + } + if ((pane?.agent ?? pane?.agent_session?.agent) === "claude" && !sendAnyway) { if (clearDraft === undefined) { try { await freeClaudeBox(paneId, pane?.agent_status === "working", authorize, arrivedAt + (options.submitDeadlineMs ?? SUBMIT_DEADLINE_MS)); } catch (error) { - if (error instanceof InputDraftError) throw new InputDraftError(error.draft, identity ?? undefined); + if (error instanceof InputDraftError) throw new InputDraftError(error.draft, identity ?? undefined, error.box); throw error; } } @@ -648,7 +703,7 @@ export function createServer( if (claudeInputDraft(...reads)) { // only the draft the user was shown goes, never what has since replaced it const draft = claudeClearableDraft(...reads); - if (clearDraft === undefined || draft !== clearDraft) throw new InputDraftError(draft, identity ?? undefined); + if (clearDraft === undefined || draft !== clearDraft) throw new InputDraftError(draft, identity ?? undefined, clearDraft === undefined ? claudeInputBoxContent(reads[0]) : null); const reserve = DRAFT_CLEAR_WAIT_MS + 500; inTime(reserve); // A confirmation names the earlier draft, not text typed while it was inspected. @@ -686,7 +741,7 @@ export function createServer( return; } catch (error) { if (!(error instanceof HerdrError)) throw error; - if (clearDraft !== undefined) throw error; + if (clearDraft !== undefined || sendAnyway) throw error; const queuedOnly = error.code === "agent_blocked" && await blockedOnlyByCodexQueue(paneId); if (error.code !== "agent_not_found" && error.code !== "agent_not_ready" && !queuedOnly) throw error; } @@ -778,10 +833,22 @@ export function createServer( // herdr reports as unknown. Nothing is inferred from a bare unknown state. const pane = (await clientSnapshot()).panes.find((candidate) => candidate.pane_id === paneId); if (!pane) throw new HerdrError("pane_not_found", `pane ${paneId} not found`); - const current = pendingIdentity(pane); - if (identity && (identity.agent !== current.agent || identity.terminalId !== current.terminalId || identity.session !== current.session)) { + const info = await paneProcessInfo(paneId); + const pid = info?.foreground_processes?.[0]?.pid ?? null; + const current: PendingIdentity = { + ...pendingIdentity(pane), processPid: pid, processBirth: pid === null ? null : processStartedAt(pid), + omoSession: omo.sessionOf(paneId)?.sessionId ?? null, + }; + if (identity && !samePendingIdentity(identity, current)) { throw new HerdrError("pending_target_changed", "The pane's terminal or agent session changed; review the message before sending it again"); } + if (identity) { + // Keep newly learned parts so a replacement before Enter is checked against them too. + identity.session ??= current.session; + identity.processPid ??= current.processPid; + identity.processBirth ??= current.processBirth; + identity.omoSession ??= current.omoSession; + } // the live screen, not the viewport: a pane scrolled into its history still draws its next // menu or password prompt at the bottom (server/prompt.ts liveScreen) const screen = (await paneRead({ paneId, source: "detection", format: "text" })).text; @@ -840,7 +907,7 @@ export function createServer( const [live, colors] = await claudeBoxReads(paneId); if (!claudeInputDraft(live, colors)) return; if (working || claudeRestoredDraft(live, colors, lastChatSubmitted(paneId)) === null) { - throw new InputDraftError(claudeClearableDraft(live, colors)); + throw new InputDraftError(claudeClearableDraft(live, colors), undefined, claudeInputBoxContent(live)); } if (deadline - Date.now() < RESTORED_CLEAR_RESERVE_MS) throw new HerdrError("submit_timeout", "Too little time was left to clear Claude Code's input box and send; nothing was typed"); // the read proved the box holds the copy: a lone Esc on an EMPTY box would open Claude's @@ -967,7 +1034,15 @@ export function createServer( for (const client of attachment.clients) { const window = client.data.output.get(paneId); const buffered = client.getBufferedAmount(); - const blocked = window?.blocked || buffered >= OUTPUT_HIGH_BYTES; + if (window) { + // an ACK client: measured by its ACK progress, and a joiner draining its replay never pauses the pty + const verdict = window.check(buffered); + if (verdict === "stop") stopSlowClient(client); + else if (verdict === "pause") paused = true; + continue; + } + // a client without ACKs has only its transport queue to go by + const blocked = buffered >= OUTPUT_HIGH_BYTES; if (!blocked) { attachment.stalled.delete(client); continue; @@ -994,7 +1069,7 @@ export function createServer( } send(client, { type: "pty-data", pane_id: paneId, data, - ...(window ? { flow: { stream_id: window.id, offset: window.write(bytes) } } : {}), + ...(window ? { flow: { stream_id: window.id, offset: window.write(bytes, wireBytes(data)) } } : {}), }); } const push = createPushService({ @@ -1019,6 +1094,14 @@ export function createServer( // herdr called it `claude` or `pi` until now: what it finished under that name is its own onFound: (paneId) => completions.adopt(paneId, "omo", OMO_ALIASES), }); + const omoSnapshot = () => readOmoSnapshot(sessionSnapshot, omo); + /** GJC panes get their status from gjc's own transcript: herdr's `working` can be a provider child's (server/gjc-status.ts) */ + const gjc = createGjcStatus({ + discover: (panes) => gjcPanes(panes), + snapshot: omoSnapshot, + onChange: (paneId, derived, _background, turn, rest) => gjcChanged(paneId, derived, turn, rest), + onFound: (paneId) => completions.adopt(paneId, "gjc", []), + }, omo); /** Claude panes whose turn ended while work it started runs in the background (server/background-wait.ts) */ const waits = new BackgroundWait(options.statusNow ?? Date.now, options.backgroundWait); /** the pane whose status event is reading its background work now: that event sends the one frame and alert of it */ @@ -1041,14 +1124,16 @@ export function createServer( codexPath: async (pane) => codexOpenRollout(pane.pane_id, pane.cwd, await paneCodexHome(pane.pane_id, options.codexHome)), onChange: (paneId, summary) => planChanged(paneId, summary), }); - /** herdr's snapshot with OmO's own status in it: what the completion tracker and web push are given */ + /** herdr's snapshot with OmO's and GJC's own status in it: what the completion tracker and web push are given */ const rawSnapshot = async (): Promise => { - const snapshot = await sessionSnapshot(); - await omo.refresh(snapshot.panes); - const named = omo.apply(snapshot); + const snapshot = await omoSnapshot(); + await gjc.refresh(snapshot.panes); + const named = gjc.apply(omo.apply(snapshot)); // not waited for: finding a transcript costs process calls, and a snapshot must not wait on them. // The plans come after: a Claude pane's transcript is the one the subagent tracker found void claudeAgents.refresh(named.panes).catch(() => undefined).then(() => plans.refresh(named.panes)).catch(() => undefined); + // an agent this server started has had no turn yet: its start screen is no work (launched-agents.ts) + for (const pane of named.panes) pane.agent_status = launchedAgents.settle(pane.pane_id, pane.agent_status); return named; }; const backgroundOf = (paneId: string): number => omo.backgroundOf(paneId) || claudeAgents.countOf(paneId); @@ -1129,6 +1214,19 @@ export function createServer( return { cols, rows }; } + /** An interact client's terminal sizes the pty: the grid is remembered as this client's, and it drives. */ + function drivePty(paneId: string, client: Client, cols: number, rows: number): void { + const attachment = attachments.get(paneId); + const claim = client.data.attached.get(paneId); + if (claim) claim.keepSize = false; + if (attachment && !attachment.mirror) { + attachment.grids.delete(client); + attachment.grids.set(client, { cols, rows }); + attachment.driver = client; + } + resizePty(paneId, cols, rows); + } + function resizePty(paneId: string, cols: number, rows: number): void { const attachment = attachments.get(paneId); // a mirrored pane's grid is herdr's own: no browser resizes it @@ -1171,6 +1269,7 @@ export function createServer( rows: spawnRows, replay: new ReplayBuffer(MAX_REPLAY_BYTES), stalled: new Map(), + grids: new Map(), }; attachments.set(paneId, attachment); @@ -1244,7 +1343,7 @@ export function createServer( attachment.pty = start(); } catch (error) { const message = spawnFailure(paneId, error); - broadcast(paneId, { type: "error", code: "command_failed", message }); + broadcast(paneId, { type: "error", code: error instanceof HerdrError ? error.code : "command_failed", message, pane_id: paneId }); broadcast(paneId, { type: "pty-exit", pane_id: paneId, code: null }); closeAttachment(paneId); } @@ -1398,7 +1497,7 @@ export function createServer( attachment.pty = start(); } catch (error) { const message = spawnFailure(paneId, error); - broadcast(paneId, { type: "error", code: "command_failed", message }); + broadcast(paneId, { type: "error", code: error instanceof HerdrError ? error.code : "command_failed", message, pane_id: paneId }); finish(); } }, (error: unknown) => { @@ -1410,8 +1509,14 @@ export function createServer( }; relookup(); }; + const command = process.env["HERDR_WEB_HERDR_BIN"] || "herdr"; + // node-pty reports a command it cannot exec only as a silent exit 1, which reads as an + // ended attach (#925): look it up on the PATH and from the home directory the sidecar uses. + if (!Bun.which(command, { PATH: process.env["PATH"] ?? "", cwd: process.env["HOME"] ?? "/" })) { + throw new HerdrError("attach_failed", `herdr could not be started: ${command} is not an executable on the server's PATH (set HERDR_WEB_HERDR_BIN to herdr's full path)`); + } const session = new PtySession({ - command: process.env["HERDR_WEB_HERDR_BIN"] || "herdr", + command, // --takeover goes after the id: before it, herdr reads the id as an unknown option args: ["terminal", "attach", attachedTerminal, ...(takingOver ? ["--takeover"] : [])], // herdr's CLI reads HERDR_SOCKET_PATH, not HERDR_SOCKET: the stream must reach @@ -1475,6 +1580,8 @@ export function createServer( if (!attachment) return; attachment.clients.delete(client); attachment.stalled.delete(client); + attachment.grids.delete(client); + if (attachment.driver === client) attachment.driver = undefined; if (attachment.clients.size === 0) closeAttachment(paneId); else reconcileOutput(paneId); } @@ -1508,6 +1615,19 @@ export function createServer( if (turn) push.onStatus(paneId, status).catch(logPushError); } + /** + * A GJC pane's own turn, which herdr's provider-child status never shows: back at work, its + * form has had its answer. gjc keeps no background records to count, so a change is always a + * turn, and an idle read after work is the finish — one ended in an error is not (#687). + */ + function gjcChanged(paneId: string, derived: AgentStatus, turn: boolean, rest: OmoRest | undefined): void { + if (turn && derived === "working") promptWaitEnded(paneId); + const status = turn ? completions.observe(paneId, derived, "gjc", rest) : completions.current(paneId) ?? completions.observe(paneId, derived, "gjc"); + if (turn) { pending.status(paneId, status); drainPending(paneId); } + broadcastAll(paneStatus(paneId, status)); + if (turn) push.onStatus(paneId, status).catch(logPushError); + } + /** * A Claude pane's subagents or background commands started or ended: no turn, so the status * stands. Nothing is alerted unless the pane began or stopped waiting on its turn's work at rest: @@ -1544,18 +1664,22 @@ export function createServer( const bridgeAgents = machines ? null : bridgeAgentNews(() => broadcastAll({ type: "session-changed" })); const collector = startStatusCollector({ - onStatus: (paneId, raw, agent, replay) => { + onStatus: (paneId, read, agent, replay) => { + const raw = launchedAgents.settle(paneId, read); + gjc.poll(); // read back from a snapshot around a gap between subscriptions. An OmO pane's status there - // is OmO's own or herdr's by turns (server/omo-status.ts), and a difference is no change - if (replay && (omo.runs(paneId) || !completions.replayed(paneId, raw, replay))) return; - // another agent took an OmO pane: what OmO worked on there is not that agent's to finish - if (omo.named(paneId, agent)) completions.forget(paneId); + // is OmO's own or herdr's by turns (server/omo-status.ts), and a difference is no change; + // a GJC pane's may be a provider child's while gjc's own transcript is tracked + if (replay && (omo.runs(paneId) || gjc.tracks(paneId) || !completions.replayed(paneId, raw, replay))) return; + // Native OmO discovery owns the pane even when herdr names its provider child. + if (reconcileProviderStatus(omo, gjc, { pane_id: paneId, agent })) completions.forget(paneId); // the frame below names no agent: one herdr names anew is read into the roster now. An OmO // pane is `omo` in every snapshot, whatever herdr calls it in an event - const named = [{ pane_id: paneId, agent: omo.runs(paneId) ? "omo" : agent }]; + const named = [{ pane_id: paneId, agent: omo.runs(paneId) ? "omo" : gjc.runs(paneId) ? "gjc" : agent }]; if (machines) machines.localAgents(named); else bridgeAgents!.status(named); - // herdr says `claude/idle` for an OmO pane whatever it does: its own status stands, and no Claude turn waits there - if (omo.tracks(paneId)) { + // herdr says `claude/idle` for an OmO pane whatever it does, and a provider child's work for + // a GJC one while its transcript is tracked: their own status stands, and no Claude turn waits there + if (omo.tracks(paneId) || gjc.tracks(paneId)) { waits.forget(paneId); return; } @@ -1566,7 +1690,7 @@ export function createServer( if (raw === "working") promptWaitEnded(paneId); // an agent herdr lost on the way still works and finishes as such (server/completion.ts); // an OmO pane whose session is not known keeps herdr's status, under its own name - const status = completions.observe(paneId, raw, omo.runs(paneId) ? "omo" : agent); + const status = completions.observe(paneId, raw, omo.runs(paneId) ? "omo" : gjc.runs(paneId) ? "gjc" : agent); pending.status(paneId, status); drainPending(paneId); // a Claude turn that ended holds on its background work: what it started is read now, while // the pane is still taken for the status before (work that ended before the turn did holds @@ -1627,6 +1751,7 @@ export function createServer( onStructureChange: () => broadcastAll({ type: "session-changed" }), }, { snapshot: rawSnapshot }); omo.start(); + gjc.start(); claudeAgents.start(); const waitTimer = setInterval(waitsRanOut, 1000); waitTimer.unref?.(); @@ -1647,6 +1772,7 @@ export function createServer( const server = Bun.serve({ port: options.port ?? (envPort ? Number(envPort) : DEFAULT_PORT), hostname, + maxRequestBodySize: BUN_BODY_CEILING, async fetch(request, bunServer) { const startup = portalStartupResult ?? await portalStartup; @@ -1662,6 +1788,7 @@ export function createServer( const forwarded = cameThroughProxy(request.headers); const funnel = request.headers.has("tailscale-funnel-request"); const tailscaleLogin = request.headers.get("tailscale-user-login"); + const forwardedFor = request.headers.get("x-forwarded-for"); const requestHost = request.headers.get("host"); // the guessing budget of the address that asks (auth.ts): a token presented while it waits is not compared const client = authClient(ip?.address ?? null, request.headers); @@ -1671,8 +1798,14 @@ export function createServer( // the connection server's own bridge token does not if (presented === "wrong" && !bridgeAuthorized) recordPresentedTokenFailure(client); const tokenMatched = presented === "match"; - const pairedDevice = devices.match(parseCookies(request.headers.get("cookie")).get(DEVICE_COOKIE)); - const requestShape = { loopback, forwarded, funnel, tailscaleLogin, serveOnly }; + let pairedDevice: DeviceMatch | null; + try { + pairedDevice = devices.match(parseCookies(request.headers.get("cookie")).get(DEVICE_COOKIE)); + } catch (error) { + // First-cookie confirmation is an authorization write, not best-effort telemetry. + return errorResponse(error); + } + const requestShape = { loopback, forwarded, funnel, tailscaleLogin, forwardedFor, serveOnly }; const identity = await identityOf(token === "" && pairedDevice === null && isServeOwnerRequest(requestShape) && (pathname === "/ws" || pathname.startsWith("/api/")), requestHost); const access = decideAccess({ ...requestShape, @@ -1711,7 +1844,9 @@ export function createServer( // protection: a watch device's subscription is delivered every status change anyway. // /api/push/test stays with a session that can drive only because it is a mutation. const ownPreferences = pathname === "/api/auth" || pathname === "/api/push/subscribe"; - if (readOnly && (fileRead || directoryListing || mutating && !ownPreferences)) { + // a repository's changes are file contents too: the patch of a credential file is that file + const repositoryDiff = /^\/api\/(?:machines\/[^/]+\/)?workspace\/diff\//.test(pathname); + if (readOnly && (fileRead || directoryListing || repositoryDiff || mutating && !ownPreferences)) { return jsonResponse({ error: { code: "read_only", message: "this device can only watch" } }, 403); } @@ -1736,6 +1871,9 @@ export function createServer( return response; } } + // Chat reads can chain several valid 10 s RPCs. Leave the 15/30 s client deadline + // in charge rather than Bun's default 10 s idle cutoff. PC proxies have their own budget above. + if (pathname === "/api/pane/read" || pathname === "/api/pane/conversation") bunServer.timeout(request, 60); if (pathname === "/ws") { if (!sameOrigin(request)) return new Response("invalid origin", { status: 403 }); const deviceId = access.level === "full" ? access.device?.id : undefined; @@ -1747,13 +1885,13 @@ export function createServer( try { relay = new MachineRelay(machines, machineId, readOnly); await relay.ready; - const upgraded = bunServer.upgrade(request, { data: { attached: new Map(), watches: new Map(), conversationInterests: new Set(), mode: readOnly ? "observe" : "interact", output: new Map(), closing: false, roles: 0, relay, deviceId, readOnly } }); + const upgraded = bunServer.upgrade(request, { data: { attached: new Map(), watches: new Map(), conversationInterests: new Set(), mode: readOnly ? "observe" : "interact", output: new Map(), closing: false, roles: 0, relay, deviceId, readOnly } }); if (upgraded) return undefined as unknown as Response; relay.close(); } catch { relay?.close(); return new Response("remote websocket unavailable", { status: 502 }); } return new Response("websocket upgrade required", { status: 426 }); } - const upgraded = bunServer.upgrade(request, { data: { attached: new Map(), watches: new Map(), conversationInterests: new Set(), mode: readOnly ? "observe" : "interact", output: new Map(), closing: false, roles: 0, deviceId, readOnly } }); + const upgraded = bunServer.upgrade(request, { data: { attached: new Map(), watches: new Map(), conversationInterests: new Set(), mode: readOnly ? "observe" : "interact", output: new Map(), closing: false, roles: 0, deviceId, readOnly } }); if (upgraded) return undefined as unknown as Response; return new Response("websocket upgrade required", { status: 426 }); } @@ -1841,7 +1979,8 @@ export function createServer( // not herdr kinds: offered where this server can run them (see shell-agent.ts) for (const kind of Object.keys(SHELL_AGENTS)) if (shellAgentExecutable(kind)) kinds.add(kind); const agents: AgentKind[] = [...kinds] - .map((kind) => ({ kind, label: AGENT_LABELS[kind] ?? kind })) + // an agent's command is its kind; the pane's shell may carry another PATH, as for omo and gjc above + .map((kind) => ({ kind, label: AGENT_LABELS[kind] ?? kind, installed: Bun.which(kind, { PATH: process.env["PATH"] ?? "" }) !== null })) .sort((left, right) => left.label.localeCompare(right.label) || left.kind.localeCompare(right.kind)); return jsonResponse({ agents }); } catch (error) { @@ -1889,6 +2028,30 @@ export function createServer( return listing === null ? badRequest("invalid_cwd", "path must be a directory this user can read") : jsonResponse(listing); } + if (pathname === "/api/workspace/diff/files" || pathname === "/api/workspace/diff/file") { + if (request.method !== "GET") return badRequest("method_not_allowed", "use GET"); + // file contents, like /api/fs/file: this app reads them, another site's page does not + if (request.headers.get("sec-fetch-site") === "cross-site") return jsonResponse({ error: { code: "cross_site", message: "A repository's changes are read from this app" } }, 403); + const paneId = url.searchParams.get("pane_id"); + const directory = (url.searchParams.get("directory") ?? "").trim(); + if (!paneId && directory === "") return badRequest("missing_directory", "pane_id or directory query parameter is required"); + let base: string | undefined; + if (paneId) { + try { base = (await paneContext(paneId)).cwd; } catch (error) { + // a named folder is still read; the pane's own folder is gone with the pane + if (directory === "") return errorResponse(error); + } + } + let cwd = base; + if (directory !== "") { + const relative = directory !== "~" && !directory.startsWith("~/") && !isAbsolute(directory); + if (relative && base === undefined) return badRequest("invalid_cwd", "a relative directory needs the pane it belongs to"); + cwd = expandedDirectory(relative ? resolve(base!, directory) : directory) ?? undefined; + } + if (cwd === undefined) return badRequest("invalid_cwd", "directory must be a directory this user can read"); + return repoDiffResponse(pathname.endsWith("/files") ? "files" : "file", cwd, url.searchParams); + } + // A tab is made the way a workspace is: herdr opens it with a shell in its root pane, and // the agent (if any) starts there through the one launch path, so names, retries and a // partial failure read the same for both. @@ -1913,6 +2076,13 @@ export function createServer( const cwd = payload.cwd === undefined ? undefined : expandedDirectory(payload.cwd); if (payload.cwd !== undefined && cwd === null) return badRequest("invalid_cwd", "cwd must be an existing directory"); if (payload.label !== undefined && typeof payload.label !== "string") return badRequest("missing_label", "label must be a string"); + if (typeof payload.label === "string") { + // a blank name is no name: herdr names the workspace after its folder, the tab by its number + const label = payload.label.trim(); + if (label.length > MAX_LABEL_CHARS) return badRequest("label_too_long", `label must be at most ${MAX_LABEL_CHARS} characters`); + if (label === "") delete payload.label; + else payload.label = label; + } const fault = agentFault(payload.agent); if (fault !== null) return badRequest("invalid_agent", fault); // agent.start can legitimately take a minute; Bun's default idle timeout is shorter. @@ -1966,6 +2136,8 @@ export function createServer( bunServer.timeout(request, 75); try { const removed = await worktreeRemove(payload.workspace_id, payload.force === true); + // herdr closed the worktree's workspace: tell clients now, as the workspace close route does + broadcastAll({ type: "session-changed" }); return jsonResponse({ ok: true, path: removed.path, forced: removed.forced }); } catch (error) { return errorResponse(error); @@ -1997,6 +2169,7 @@ export function createServer( } catch (response) { return response as Response; } + if (label !== undefined && label.length > MAX_LABEL_CHARS) return badRequest("label_too_long", `label must be at most ${MAX_LABEL_CHARS} characters`); // a checkout path is taken as git names it, spaces and all let path: string | undefined; if (payload.path !== undefined && payload.path !== null) { @@ -2037,6 +2210,11 @@ export function createServer( ...(agent ? await agentLaunch(opened.root_pane.pane_id, agent) : {}), }); } catch (error) { + // git's own words for a branch that has no commit yet (a folder just `git init`ed): with no + // base named there is nothing to check out from, which the dialog says in its own words + if (creating && base === undefined && error instanceof HerdrError && error.code === "worktree_create_failed" && /invalid reference: HEAD\b/u.test(error.message)) { + return conflictResponse("repository_without_commits", "the repository has no commits yet"); + } return errorResponse(error); } } @@ -2056,8 +2234,12 @@ export function createServer( if (pathname === "/api/workspace/close" && payload.close_group !== undefined && typeof payload.close_group !== "boolean") { return badRequest("invalid_close_group", "close_group must be a boolean"); } - if (pathname === "/api/workspace/rename" && typeof payload.label !== "string") { - return badRequest("missing_label", "label is required"); + if (pathname === "/api/workspace/rename") { + // herdr would keep an empty label, and the sidebar row would show no name + const label = typeof payload.label === "string" ? payload.label.trim() : ""; + if (label === "") return badRequest("missing_label", "label is required"); + if (label.length > MAX_LABEL_CHARS) return badRequest("label_too_long", `label must be at most ${MAX_LABEL_CHARS} characters`); + payload.label = label; } if (pathname === "/api/workspace/move" && (typeof payload.insert_index !== "number" || !Number.isInteger(payload.insert_index) || payload.insert_index < 0)) { return badRequest("invalid_index", "insert_index must be a non-negative integer"); @@ -2066,6 +2248,9 @@ export function createServer( if (pathname === "/api/workspace/rename") await workspaceRename(payload.workspace_id, payload.label as string); else if (pathname === "/api/workspace/move") await workspaceMove(payload.workspace_id, payload.insert_index as number); else await workspaceClose(payload.workspace_id, undefined, payload.close_group === true); + // a closed workspace's panes send no exit: without this, clients learn of it only after + // the collector's structure debounce, and the closed workspace stays on screen meanwhile + if (pathname === "/api/workspace/close") broadcastAll({ type: "session-changed" }); return jsonResponse({ ok: true }); } catch (error) { return errorResponse(error); @@ -2096,6 +2281,7 @@ export function createServer( // herdr would keep an empty label as the tab's name, and its own tab row would show nothing const label = typeof payload.label === "string" ? payload.label.trim() : ""; if (label === "") return badRequest("missing_label", "label is required"); + if (label.length > MAX_LABEL_CHARS) return badRequest("label_too_long", `label must be at most ${MAX_LABEL_CHARS} characters`); await tabRename(payload.tab_id, label); // no pane event follows a rename, so the clients are told here broadcastAll({ type: "session-changed" }); @@ -2198,30 +2384,6 @@ export function createServer( } } - // herdr's `pane move`: into another tab, a new tab or a new workspace. herdr emits - // pane.moved, and the collector's session-changed broadcast redraws every client. - if (pathname === "/api/pane/move") { - if (request.method !== "POST") return badRequest("method_not_allowed", "use POST"); - let payload: unknown; - try { - payload = await request.json(); - } catch { - return badRequest("invalid_json", "request body must be JSON"); - } - if (!isJsonObject(payload)) return badRequest("invalid_body", "request body must be a JSON object"); - const parsed = parseMoveRequest(payload); - if ("problem" in parsed) return badRequest(parsed.problem.code, parsed.problem.message); - try { - const moved = await paneMove(parsed.params); - // a pane that left its workspace answers to a new id: what its chat parsed under the - // old one is released (server/conversation.ts) and read again under the new - if (moved.previous_pane_id !== moved.pane.pane_id) forgetPaneTranscriptState(moved.previous_pane_id); - return jsonResponse(moved satisfies PaneMoved); - } catch (error) { - return errorResponse(error); - } - } - if (pathname === "/api/agent/rename") { if (request.method !== "POST") return badRequest("method_not_allowed", "use POST"); let payload: Partial>; @@ -2351,9 +2513,9 @@ export function createServer( try { return jsonResponse(await paneFind({ pane_id: payload.pane_id, query: payload.query, direction: payload.direction, - ...(typeof payload.jump === "boolean" ? { jump: payload.jump } : {}), + ...(payload.jump !== undefined ? { jump: payload.jump } : {}), ...(isJsonObject(previous) && point(previous.start) && point(previous.end) ? { previous: { start: previous.start, end: previous.end } } : {}), - ...(isCount(payload.content_revision) ? { content_revision: payload.content_revision } : {}), + ...(payload.content_revision !== undefined ? { content_revision: payload.content_revision } : {}), })); } catch (error) { if (error instanceof HerdrError && error.code === "stale_content") return conflictResponse(error.code, error.message); @@ -2583,8 +2745,9 @@ export function createServer( if (request.method !== "POST") return badRequest("method_not_allowed", "use POST"); let payload: { pane_id?: string; content_type?: string; data_base64?: string; name?: string }; try { - payload = (await request.json()) as typeof payload; - } catch { + payload = JSON.parse(await readImageBody(request)) as typeof payload; + } catch (error) { + if (error instanceof PasteImageError) return jsonResponse({ error: { code: error.code, message: error.message } }, error.status); return badRequest("invalid_json", "request body must be JSON"); } if (!isJsonObject(payload)) return badRequest("invalid_body", "request body must be a JSON object"); @@ -2679,6 +2842,15 @@ export function createServer( } try { switch (message.type) { + // liveness only: any role, no herdr call, nothing to wait behind + case "ping": { + if (!Number.isSafeInteger(message.id)) { + send(client, { type: "error", code: "invalid_ping", message: "id must be an integer" }); + break; + } + send(client, { type: "pong", id: message.id }); + break; + } case "watch": { const geometry = validGeometry(message.cols, message.rows); if (!geometry) { @@ -2725,7 +2897,8 @@ export function createServer( // record the pane before the await: a detach (switching panes) or a close // that lands while the terminal is looked up must cancel this attach, and // neither can see a client that only joins the attachment afterwards - const claim = client.data.attached.get(message.pane_id) ?? {}; + const claim = client.data.attached.get(message.pane_id) ?? { keepSize: false }; + claim.keepSize = message.keep_size === true; if (!client.data.attached.has(message.pane_id)) client.data.attached.set(message.pane_id, claim); let attachment: PaneAttachment; try { @@ -2761,17 +2934,22 @@ export function createServer( // hand the newcomer the current screen it would otherwise have missed // (a mirror keeps its latest screen whole; a pty keeps a bounded tail of its stream) const replay = attachment.mirror ? attachment.mirror.current ?? "" : attachment.replay.text(); - if (!alreadyAttached && replay) sendOutput(client, message.pane_id, replay); + if (!alreadyAttached && replay) { + client.data.output.get(message.pane_id)?.join(replay, client.getBufferedAmount()); + sendOutput(client, message.pane_id, replay); + } // a pane waiting for another web bridge to let go says so to each newcomer, too if (!alreadyAttached && attachment.held) send(client, { type: "error", code: "attach_held", message: ATTACH_HELD_MESSAGE, pane_id: message.pane_id }); reconcileOutput(message.pane_id); if (client.data.closing) break; if (attachment.ready && !attachment.held) send(client, { type: "input-ready", pane_id: message.pane_id }); if (attachment.mirror) break; - if (client.data.mode === "interact" && message.keep_size !== true) { + if (client.data.mode === "interact" && !claim.keepSize) { // an operator's viewport owns the shared grid - resizePty(message.pane_id, geometry.cols, geometry.rows); + drivePty(message.pane_id, client, geometry.cols, geometry.rows); } else { + // a covered grid is not a terminal showing: a release never hands the pty to it + attachment.grids.delete(client); // an observer, or a grid the chat lens covers, adopts the grid the operators left behind send(client, { type: "pane-geometry", @@ -2887,7 +3065,11 @@ export function createServer( for (const [pane, at] of lastTyped) if (Date.now() - at > TYPED_SETTLE_MS) { lastTyped.delete(pane); typedEscape.delete(pane); } } }; - if (paneQueues.has(message.pane_id)) { + // the composer's Stop: a message sent right after it must not reach the agent first, and a + // pty write only queues the ESC in the sidecar, so it goes the message's own way (send_text) + // in the pane's turn. Plain typing, a terminal ESC included, keeps the pty's FIFO order. + const stop = message.stop === true && message.text === "\u001b"; + if (paneQueues.has(message.pane_id) || stop) { const text = message.text; // a click or a wheel (a click replayed behind a held arrow) is the attach's own mouse: // herdr encodes it for the program's mouse mode, send_text would type its bytes (#667) @@ -2898,7 +3080,9 @@ export function createServer( const roles = client.data.roles; const allowed = () => mayType(client) && client.data.roles === roles && attachments.get(message.pane_id) === attachment && attachment.pty === pty && client.data.attached.get(message.pane_id) === claim && attachment.clients.has(client) && attachment.ready && !attachment.held; - void serialize(message.pane_id, () => { + void serialize(message.pane_id, async () => { + // behind typing still in the sidecar, as a terminal key waits for it + if (stop) await settleAfterTyping(message.pane_id); // held while this waited its turn: it goes nowhere, as unqueued typing would if (attachments.get(message.pane_id) !== attachment || attachment.pty !== pty || client.data.attached.get(message.pane_id) !== claim @@ -2911,7 +3095,10 @@ export function createServer( typedIntoPty(text); return; } - return paneSendText(message.pane_id, text, undefined, allowed); + await paneSendText(message.pane_id, text, undefined, allowed); + // herdr still holds a lone ESC a moment: a message after the Stop waits out the same + // settling gap as after a typed one, and checks again for a question before pasting + if (stop) typedIntoPty(text); }).catch(inputFailed); } else { if (!attachment.pty.write(message.text)) { inputFailed(); break; } @@ -2932,12 +3119,35 @@ export function createServer( // the pty is still being created (an attach from the chat lens, then the switch to the // terminal lens before the terminal was looked up): the resize waits for it. Dropped, // it would leave the pty at the pane's own grid under a terminal fitted to another. + const claim = client.data.attached.get(message.pane_id); + if (claim) claim.keepSize = false; const creating = attachments.has(message.pane_id) ? undefined : pendingAttachments.get(message.pane_id); if (creating && client.data.attached.has(message.pane_id)) { try { await creating; } catch { break; } - if (client.data.closing || client.data.mode !== "interact" || !client.data.attached.has(message.pane_id)) break; + if (client.data.closing || client.data.mode !== "interact" || client.data.attached.get(message.pane_id) !== claim || claim?.keepSize) break; } - resizePty(message.pane_id, geometry.cols, geometry.rows); + drivePty(message.pane_id, client, geometry.cols, geometry.rows); + break; + } + case "release-size": { + // this client's terminal is hidden, out of use or covered by Chat: if its grid is the pty's, the pty + // goes back to the latest grid of another interact client still showing the terminal (#472). + // Nothing to hand it to, or another client drove since: the size stays. Never an error, as + // an older server ignores the frame. + if (client.data.mode !== "interact") break; + const claim = client.data.attached.get(message.pane_id); + if (!claim) break; + // Remember the release before looking up the attachment: its initial creation can + // still be awaiting herdr, and must not revive this hidden grid when it completes. + claim.keepSize = true; + const attachment = typeof message.pane_id === "string" ? attachments.get(message.pane_id) : undefined; + if (!attachment || attachment.mirror) break; + attachment.grids.delete(client); + if (attachment.driver !== client) break; + attachment.driver = undefined; + const next = [...attachment.grids].reverse().find(([other]) => other.data.mode === "interact" + && attachment.clients.has(other) && other.data.attached.get(message.pane_id)?.keepSize === false); + if (next) drivePty(message.pane_id, next[0], next[1].cols, next[1].rows); break; } case "keys": { @@ -2984,11 +3194,7 @@ export function createServer( // A key goes through herdr's RPC, around the attach pty that typing just went into: it // waits for that typing as a composer message does, or it overtakes it (a lone ESC is // held there ~150ms). The checks below run after the wait. - if (origin && !origin.mirror) { - const settle = typedEscape.has(message.pane_id) ? TYPED_SETTLE_MS : KEY_SETTLE_MS; - const typed = Date.now() - (lastTyped.get(message.pane_id) ?? 0); - if (typed < settle) await Bun.sleep(settle - typed); - } + if (origin && !origin.mirror) await settleAfterTyping(message.pane_id); // the attach this chord was pressed in is gone (left, replaced, or left and joined again). // `input_failed`, as queued typing answers: `input_not_ready` makes the client drop the // pane's readiness, and the attach it holds by now has already been told it is ready @@ -3044,6 +3250,8 @@ export function createServer( if (client.data.mode === "observe") { result(false, "read_only"); break; } const attachment = attachments.get(message.pane_id); if (!attachment?.clients.has(client)) { result(false, "not_attached"); break; } + // Mirrored cleanup uses Backspace on Windows too: only printable ASCII erases reliably. + if (attachment.mirror && /[^\x20-\x7e]/.test(message.secret)) { result(false, "invalid_secret"); break; } if (!attachment.ready || attachment.held) { result(false, "input_not_ready"); break; } if (paneQueues.has(message.pane_id)) { result(false, "pane_busy"); break; } // the attach this secret was sent from: leaving it (even joining again) takes the secret back @@ -3074,9 +3282,18 @@ export function createServer( if (error instanceof HerdrError && error.code === "cancelled") { result(false, "not_attached"); return; } throw error; } - // Once the text is in, its Enter follows unchecked: withheld, it would leave the - // secret on the prompt line for the next Enter anyone presses (#589). - await paneSendKeys(message.pane_id, ["Enter"]); + try { + // Typing finished; recheck authority after the Enter RPC connects (#900). + await paneSendKeys(message.pane_id, ["Enter"], undefined, allowed); + } catch (error) { + if (!(error instanceof HerdrError && error.code === "cancelled")) throw error; + // Cleanup is deliberately unguarded: do not leave the secret for another Enter. + // Raw-mode readers may receive these bytes without erasing their own buffer. + await paneSendKeys(message.pane_id, Array(message.secret.length).fill("Backspace")); + result(false, client.data.closing || client.data.roles !== roles + ? "read_only" : "not_attached"); + return; + } } else { // Direct attach keystrokes: no agent transcript, RPC payload or delayed Enter. if (!attachment.pty.write(`${message.secret}\r`)) { result(false, "input_not_ready"); return; } @@ -3091,9 +3308,9 @@ export function createServer( } case "submit": { // every submit is answered: the composer keeps its text until it hears back - const result = (ok: boolean, code?: string, text?: string, accepted?: PendingMessage, draft?: string | null) => send(client, { + const result = (ok: boolean, code?: string, text?: string, accepted?: PendingMessage, draft?: string | null, box?: string | null) => send(client, { type: "submit-result", id: message.id, pane_id: message.pane_id, ok, ...(accepted ? { pending: accepted } : {}), ...(code ? { code, message: text } : {}), - ...(draft ? { draft } : {}), + ...(draft ? { draft } : {}), ...(!draft && typeof box === "string" ? { box } : {}), }); if (!Number.isSafeInteger(message.id) || typeof message.pane_id !== "string" || !message.pane_id || typeof message.text !== "string" || typeof message.payload !== "string") { @@ -3111,6 +3328,20 @@ export function createServer( result(false, "invalid_delivery", "Clear a terminal draft only for a chat message sent at once"); break; } + if (message.send_anyway !== undefined && (message.send_anyway !== true || waits || message.clear_draft !== undefined + || (message.typed !== undefined && message.typed !== false))) { + result(false, "invalid_delivery", "Send anyway only a chat message sent at once, without clearing a draft"); + break; + } + if (message.resend_id !== undefined && (typeof message.resend_id !== "string" || !message.resend_id || message.resend_id.length > 128 + || waits || message.typed === true || message.clear_draft !== undefined || message.send_anyway !== undefined)) { + result(false, "invalid_delivery", "Use a held-copy resend id only for an immediate chat message"); + break; + } + if (message.resend_generation !== undefined && (!Number.isSafeInteger(message.resend_generation) || message.resend_generation < 0 || message.resend_id === undefined)) { + result(false, "invalid_delivery", "A resend generation must be a nonnegative integer with a held-copy id"); + break; + } if (client.data.mode === "observe") { result(false, "read_only", "this connection is in observe mode"); break; @@ -3155,22 +3386,36 @@ export function createServer( drainPending(message.pane_id); break; } - await serialize(message.pane_id, () => { + const dispatch = () => { // held while this waited its turn (the attach was refused after the check above) if (attachments.get(message.pane_id)?.held) throw new HerdrError("attach_held", ATTACH_HELD_MESSAGE); return submitText(message.pane_id, message.text, message.payload, arrivedAt, message.typed === true, () => authorizeSocket(client), message.clear_draft, - draftConfirmations.get(client)?.get(message.pane_id)); - }); - if (message.clear_draft !== undefined) draftConfirmations.get(client)?.delete(message.pane_id); + draftConfirmations.get(client)?.get(message.pane_id), message.send_anyway === true); + }; + if (message.resend_id !== undefined) { + authorizeSocket(client); + const answer = await serialize(message.pane_id, () => pending.resend(message.pane_id, message.resend_id!, + new Bun.CryptoHasher("sha256").update(message.text).digest("hex"), async () => { + try { await dispatch(); return { ok: true }; } + catch (error) { return { ok: false, ...pendingFault(error) }; } + }, message.resend_generation)); + result(answer.ok, answer.code, answer.message); + break; + } + await serialize(message.pane_id, dispatch); + if (message.clear_draft !== undefined || message.send_anyway) draftConfirmations.get(client)?.delete(message.pane_id); result(true); } catch (error) { - if (message.clear_draft !== undefined) draftConfirmations.get(client)?.delete(message.pane_id); - if (error instanceof InputDraftError && error.draft && error.identity) { + if (message.clear_draft !== undefined || message.send_anyway) draftConfirmations.get(client)?.delete(message.pane_id); + // a refusal naming a draft confirms it for Clear it and send; one naming none, for Send anyway + if (error instanceof InputDraftError && error.identity && (error.draft || error.box !== null)) { let confirmations = draftConfirmations.get(client); if (!confirmations) { confirmations = new Map(); draftConfirmations.set(client, confirmations); } - confirmations.set(message.pane_id, { draft: error.draft, identity: error.identity }); + confirmations.set(message.pane_id, { draft: error.draft, identity: error.identity, box: error.box }); } - const fault = pendingFault(error); result(false, fault.code, fault.message, undefined, error instanceof InputDraftError ? error.draft : null); + const fault = pendingFault(error); + result(false, fault.code, fault.message, undefined, error instanceof InputDraftError ? error.draft : null, + error instanceof InputDraftError && error.identity && error.box !== null ? shownInputBox(error.box) : null); } break; } @@ -3180,6 +3425,9 @@ export function createServer( || typeof message.pending_id !== "string" || !message.pending_id || !["steer", "discard"].includes(message.action)) { result({ ok: false, code: "invalid_pending_action", message: "Use steer or discard with a pending-message id" }); break; } + if (message.resend_generation !== undefined && (!Number.isSafeInteger(message.resend_generation) || message.resend_generation < 0)) { + result({ ok: false, code: "invalid_pending_action", message: "A resend generation must be a nonnegative integer" }); break; + } const arrivedAt = Date.now(); try { authorizeSocket(client); @@ -3192,10 +3440,21 @@ export function createServer( ? { ok: true } : { ok: false, code: "pending_not_found", message: "This pending message does not belong to this connection" }; if (message.action === "discard") return pending.discard(item) ? { ok: true } : { ok: false, code: "pending_busy", message: "This message is already being sent" }; if (item.message.state === "uncertain") return { ok: false, code: "pending_uncertain", message: "Check the terminal before sending this message again" }; - await pendingContext(client, item.paneId, lease!, item.identity); - if (!pending.claim(item, false)) return { ok: false, code: "pending_busy", message: "This message is already being sent" }; - item.lease = lease!; - return dispatchPending(item, false, arrivedAt, true); + let dispatched = false; + const reply = await pending.resend(item.paneId, item.message.id, + new Bun.CryptoHasher("sha256").update(item.message.text).digest("hex"), async (): Promise => { + try { + await pendingContext(client, item.paneId, lease!, item.identity); + if (!pending.claim(item, false)) return { ok: false, code: "pending_busy", message: "This message is already being sent" }; + item.lease = lease!; + dispatched = true; + return await dispatchPending(item, false, arrivedAt, true); + } catch (error) { return { ok: false, ...pendingFault(error) }; } + }, message.resend_generation); + // A saved copy may already have completed this ID on another connection. + if (!dispatched && pending.get(client, item.paneId, item.message.id) === item && pending.claim(item, false)) + pending.settle(item, reply.ok ? undefined : { code: reply.code!, message: reply.message! }, reply.code === "submit_changed"); + return reply; }); result(answer); drainPending(message.pane_id); @@ -3226,7 +3485,10 @@ export function createServer( } } catch (error) { const code = error instanceof HerdrError ? error.code : "command_failed"; - send(client, { type: "error", code, message: error instanceof Error ? error.message : String(error) }); + // an attach that could not start names its pane: the panes of a PC share this connection, and one + // without a pane would end every terminal on it + const failed = code === "attach_failed" && message.type === "attach" ? { pane_id: message.pane_id } : {}; + send(client, { type: "error", code, message: error instanceof Error ? error.message : String(error), ...failed }); } }, @@ -3282,6 +3544,7 @@ export function createServer( clearInterval(outputTimer); collector.stop(); omo.stop(); + gjc.stop(); claudeAgents.stop(); clearInterval(waitTimer); plans.stop(); @@ -3317,7 +3580,7 @@ if (import.meta.main) { if (process.env["HERDR_WEB_MANAGED"] === "1") process.on("disconnect", shutdown); console.log(`herdr-web-ui listening on http://${instance.hostname}:${instance.port}`); if (!isLoopbackBind(instance.hostname)) { - if ((process.env["HERDR_WEB_TOKEN"] ?? "") === "") { + if (lanExposed({ hostname: instance.hostname, tokenConfigured: (process.env["HERDR_WEB_TOKEN"] ?? "") !== "", gated: false })) { console.error( `WARNING: listening on ${instance.hostname} without HERDR_WEB_TOKEN - until a device is paired (Settings → Devices, on this PC) anyone who can reach this address can type into your terminals; pair your devices, set HERDR_WEB_TOKEN=, or keep HOST=127.0.0.1 and reach it through Tailscale or an SSH tunnel.`, ); diff --git a/server/install-lock.test.ts b/server/install-lock.test.ts new file mode 100644 index 000000000..34ed34882 --- /dev/null +++ b/server/install-lock.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { POSIX_INSTALL_LOCK, POSIX_INSTALL_RELEASE } from "./remote-host.ts"; + +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + +function acquire() { + const base = mkdtempSync(join(tmpdir(), "install-lock-")); + dirs.push(base); + const lock = join(base, "install.lock"); + const run = () => Bun.spawnSync(["sh", "-c", `set -eu; base=${JSON.stringify(base)}; ${POSIX_INSTALL_LOCK} printf acquired`]); + const sh = (script: string) => Bun.spawnSync(["sh", "-c", `set -eu; base=${JSON.stringify(base)}; ${script}`]); + return { lock, run, sh }; +} + +describe("POSIX install lock", () => { + it("takes a free lock and records its owner", () => { + const { lock, run } = acquire(); + const result = run(); + expect(result.exitCode).toBe(0); + expect(existsSync(join(lock, "pid"))).toBe(true); + }); + + it("refuses a lock held by a live installer", () => { + const { lock, run } = acquire(); + mkdirSync(lock); + writeFileSync(join(lock, "pid"), String(process.pid)); + const result = run(); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Another installation is in progress"); + expect(readFileSync(join(lock, "pid"), "utf8")).toBe(String(process.pid)); + }); + + it("reclaims a lock whose owner is gone", () => { + const { lock, run } = acquire(); + mkdirSync(lock); + writeFileSync(join(lock, "pid"), "2147483646"); + expect(run().exitCode).toBe(0); + expect(readFileSync(join(lock, "pid"), "utf8")).not.toBe("2147483646"); + }); + + it("reclaims a lock older than any install can run, even with a live owner", () => { + const { lock, run } = acquire(); + mkdirSync(lock); + writeFileSync(join(lock, "pid"), String(process.pid)); + const old = new Date(Date.now() - 40 * 60_000); + utimesSync(lock, old, old); + expect(run().exitCode).toBe(0); + expect(readFileSync(join(lock, "pid"), "utf8")).not.toBe(String(process.pid)); + }); + + it("leaves the lock alone while another installer holds the reclaim mutex", () => { + const { lock, run } = acquire(); + mkdirSync(lock); + writeFileSync(join(lock, "pid"), "2147483646"); + mkdirSync(`${lock}.reclaim`); + const result = run(); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Another installation is in progress"); + expect(readFileSync(join(lock, "pid"), "utf8")).toBe("2147483646"); + }); + + it("drops a reclaim mutex its holder died with, so the retry takes the lock", () => { + const { lock, run } = acquire(); + mkdirSync(lock); + writeFileSync(join(lock, "pid"), "2147483646"); + mkdirSync(`${lock}.reclaim`); + const old = new Date(Date.now() - 5 * 60_000); + utimesSync(`${lock}.reclaim`, old, old); + expect(run().exitCode).toBe(1); + expect(existsSync(`${lock}.reclaim`)).toBe(false); + expect(run().exitCode).toBe(0); + expect(existsSync(`${lock}.reclaim`)).toBe(false); + }); + + it("releases a lock it still owns", () => { + const { lock, sh } = acquire(); + expect(sh(`${POSIX_INSTALL_LOCK} ${POSIX_INSTALL_RELEASE}`).exitCode).toBe(0); + expect(existsSync(lock)).toBe(false); + }); + + it("keeps a lock a newer installer took over", () => { + const { lock, sh } = acquire(); + mkdirSync(lock); + writeFileSync(join(lock, "pid"), "2147483646"); + expect(sh(`lock="$base/install.lock"; ${POSIX_INSTALL_RELEASE}`).exitCode).toBe(0); + expect(readFileSync(join(lock, "pid"), "utf8")).toBe("2147483646"); + }); +}); diff --git a/server/large-history-agents.test.ts b/server/large-history-agents.test.ts new file mode 100644 index 000000000..4a42a5ba3 --- /dev/null +++ b/server/large-history-agents.test.ts @@ -0,0 +1,58 @@ +import { afterAll, expect, test } from "bun:test"; +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { forgetTranscriptState, transcriptPage } from "./conversation.ts"; + +// Every agent's chat keeps reading past 64 MiB of history: pages bound each read, the file's size +// does not. Pi has its own test (pi-large-history.test.ts); this holds Claude and Codex to the same. +const dir = mkdtempSync(join(tmpdir(), "large-history-agents-")); +// the readers keep module-wide caches keyed by path: leave none behind for the files removed here +afterAll(() => { forgetTranscriptState(); rmSync(dir, { recursive: true, force: true }); }); +const pad = "x".repeat(512 * 1024); +const TURNS = 140; +const texts = (page: ReturnType) => page.turns.flatMap((turn) => turn.parts).flatMap((part) => part.kind === "text" ? [part.text] : []); + +function walk(source: "claude-transcript" | "codex-transcript", path: string, home?: string): Set { + expect(statSync(path).size).toBeGreaterThan(64 * 1024 * 1024); + let page = transcriptPage(source, path, {}, home); + expect(page.source).toBe(source); + expect(texts(page).some((text) => text.startsWith(`answer ${TURNS - 1}`))).toBe(true); + const prompts = new Set(); + for (let pages = 0; ; pages++) { + expect(pages).toBeLessThan(200); + for (const text of texts(page)) if (text.startsWith("prompt ")) prompts.add(text); + if (!page.cursor) break; + page = transcriptPage(source, path, { before: page.cursor }, home); + } + return prompts; +} + +test("a Claude transcript past 64 MiB pages back to its first prompt", () => { + const path = join(dir, "claude.jsonl"); + writeFileSync(path, ""); + for (let n = 0; n < TURNS; n++) { + appendFileSync(path, `${JSON.stringify({ type: "user", message: { role: "user", content: `prompt ${n}` } })}\n`); + appendFileSync(path, `${JSON.stringify({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: `answer ${n} ${pad}` }] } })}\n`); + } + const prompts = walk("claude-transcript", path); + expect(prompts.size).toBe(TURNS); + expect(prompts.has("prompt 0")).toBe(true); +}); + +test("a Codex rollout past 64 MiB pages back to its first prompt", () => { + const home = join(dir, "codex"); + mkdirSync(join(home, "sessions", "2026", "10", "08"), { recursive: true }); + const path = join(home, "sessions", "2026", "10", "08", "rollout-2026-10-08T00-00-00-01a0a337-19e8-7712-92f5-aa0883392afd.jsonl"); + writeFileSync(path, `${JSON.stringify({ type: "session_meta", payload: { source: "cli", thread_source: "user" } })}\n`); + for (let n = 0; n < TURNS; n++) { + appendFileSync(path, [ + { type: "event_msg", timestamp: "2026-10-08T00:00:00.000Z", payload: { type: "task_started" } }, + { type: "response_item", timestamp: "2026-10-08T00:00:00.000Z", payload: { type: "message", role: "user", content: [{ type: "input_text", text: `prompt ${n}` }] } }, + { type: "response_item", timestamp: "2026-10-08T00:00:01.000Z", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: `answer ${n} ${pad}` }] } }, + ].map((entry) => JSON.stringify(entry)).join("\n") + "\n"); + } + const prompts = walk("codex-transcript", path, home); + expect(prompts.size).toBe(TURNS); + expect(prompts.has("prompt 0")).toBe(true); +}); diff --git a/server/launched-agents.test.ts b/server/launched-agents.test.ts new file mode 100644 index 000000000..1914b87c3 --- /dev/null +++ b/server/launched-agents.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "bun:test"; + +import { LaunchedAgents, STARTUP_MS } from "./launched-agents.ts"; + +describe("LaunchedAgents", () => { + it("answers a conversation not begun for a pane it started an agent in, under one history", () => { + const launched = new LaunchedAgents(); + launched.launched("w1:p1", "codex", 0); + const first = launched.notBegun("w1:p1", undefined, 10); + expect(first).toEqual({ source: "codex-transcript", historyId: "unwritten:launch:w1:p1:1" }); + expect(launched.notBegun("w1:p1", "done", 5_000)).toEqual(first); + expect(launched.notBegun("w1:p2", "idle", 10)).toBeNull(); + }); + + it("takes the status of an agent not yet seen at rest for its startup's", () => { + const launched = new LaunchedAgents(); + launched.launched("w1:p1", "codex", 0); + expect(launched.notBegun("w1:p1", "working", 4_000)).not.toBeNull(); + launched.launched("w2:p1", "claude", 0); + expect(launched.notBegun("w2:p1", "blocked", 4_000)).not.toBeNull(); + }); + + it("ends with the first turn: at work or asking after it rested", () => { + const launched = new LaunchedAgents(); + launched.launched("w1:p1", "codex", 0); + expect(launched.notBegun("w1:p1", "idle", 5_000)).not.toBeNull(); + expect(launched.notBegun("w1:p1", "working", 6_000)).toBeNull(); + // the turn is over and still no transcript was found: the terminal stands in, as before + expect(launched.notBegun("w1:p1", "done", 60_000)).toBeNull(); + }); + + it("takes an agent at work long after its launch for one on its first turn", () => { + const launched = new LaunchedAgents(); + launched.launched("w1:p1", "opencode", 0); + expect(launched.notBegun("w1:p1", "working", STARTUP_MS + 1)).toBeNull(); + expect(launched.notBegun("w1:p1", "idle", STARTUP_MS + 2)).toBeNull(); + }); + + it("reads a start screen's working and done as rest, and the first turn's as they are", () => { + const launched = new LaunchedAgents(); + launched.launched("w1:p1", "codex", 0); + expect(launched.settle("w1:p1", "unknown", 500)).toBe("unknown"); + expect(launched.settle("w1:p1", "working", 4_000)).toBe("idle"); + expect(launched.settle("w1:p1", "done", 4_500)).toBe("idle"); + expect(launched.settle("w1:p1", "working", 9_000)).toBe("working"); + expect(launched.settle("w1:p1", "done", 12_000)).toBe("done"); + }); + + it("keeps a question asked at the start, and every status of a pane it did not start", () => { + const launched = new LaunchedAgents(); + launched.launched("w1:p1", "claude", 0); + expect(launched.settle("w1:p1", "blocked", 4_000)).toBe("blocked"); + expect(launched.settle("w1:p1", "idle", 6_000)).toBe("idle"); + expect(launched.settle("w2:p1", "working", 4_000)).toBe("working"); + expect(launched.settle("w2:p1", "done", 4_500)).toBe("done"); + }); + + it("keeps the terminal's fallback for a kind it reads no transcript of", () => { + const launched = new LaunchedAgents(); + launched.launched("w1:p1", "muse", 0); + expect(launched.notBegun("w1:p1", "idle", 10)).toBeNull(); + expect(launched.notBegun("w1:p1", "idle", 10)).toBeNull(); + launched.launched("w1:p2", "constructor", 0); + expect(launched.notBegun("w1:p2", "idle", 10)).toBeNull(); + }); + + it("gives a pane id launched again a history of its own, and forgets a pane that ended", () => { + const launched = new LaunchedAgents(); + launched.launched("w1:p1", "claude", 0); + const first = launched.notBegun("w1:p1", "idle", 10); + launched.launched("w1:p1", "claude", 20); + expect(launched.notBegun("w1:p1", "idle", 30)?.historyId).not.toBe(first?.historyId); + launched.forget("w1:p1"); + expect(launched.notBegun("w1:p1", "idle", 40)).toBeNull(); + }); +}); diff --git a/server/launched-agents.ts b/server/launched-agents.ts new file mode 100644 index 000000000..21792b77e --- /dev/null +++ b/server/launched-agents.ts @@ -0,0 +1,63 @@ +import type { AgentStatus } from "../shared/protocol.ts"; +import type { RecognizedConversation } from "./conversation.ts"; + +/** + * Panes this server started an agent in (New workspace, New tab, New worktree), until their + * first turn. Most agents write no session until the first message (Codex its rollout, Claude + * its session id, OpenCode its session row), so nothing ties such a pane to a transcript and its + * chat would read as unavailable. The launch itself is the evidence: it resumed nothing, so the + * conversation is one with no turns yet. + * + * It ends with the first turn (the agent at work or asking after it was seen at rest), with a + * transcript found for the pane, or with the pane. Until the agent was seen at rest its status is + * its startup's: Codex reports `working` while it draws, Claude `blocked` at its folder question. + */ +const SOURCES: Record = { + agy: "agy-transcript", claude: "claude-transcript", codex: "codex-transcript", devin: "devin-transcript", + gjc: "gjc-transcript", grok: "grok-transcript", hermes: "hermes-transcript", omo: "omo-transcript", + omp: "omp-transcript", opencode: "opencode-transcript", pi: "pi-transcript", +}; + +/** A startup longer than this is taken for a first turn: nobody read the pane's chat while it rested. */ +export const STARTUP_MS = 30_000; + +interface Launch { source: RecognizedConversation["source"]; serial: number; at: number; rested: boolean } + +export class LaunchedAgents { + private readonly panes = new Map(); + private serial = 0; + + /** `kind`: one this server reads a transcript of; a pane of any other keeps the terminal's fallback. */ + launched(paneId: string, kind: string, now = Date.now()): void { + const source = Object.hasOwn(SOURCES, kind) ? SOURCES[kind] : undefined; + if (source === undefined) { this.panes.delete(paneId); return; } + this.serial += 1; + this.panes.set(paneId, { source, serial: this.serial, at: now, rested: false }); + } + + /** The conversation not begun that stands for a pane no transcript was found for, or null once its first turn began. */ + notBegun(paneId: string, status: AgentStatus | undefined, now = Date.now()): { source: RecognizedConversation["source"]; historyId: string } | null { + const launch = this.panes.get(paneId); + if (launch === undefined) return null; + const busy = status === "working" || status === "blocked"; + if (!busy && (status === "idle" || status === "done")) launch.rested = true; + if (busy && (launch.rested || now - launch.at > STARTUP_MS)) { this.panes.delete(paneId); return null; } + return { source: launch.source, historyId: `unwritten:launch:${paneId}:${launch.serial}` }; + } + + /** + * The status to take for one herdr read off the pane. Before the first turn there is no work and + * no finish: an agent drawing its start screen reads `working` and then `done` (Codex, live + * 2026-10-14), which showed DONE on a pane nobody had asked anything of. A question it asks + * (Claude's folder question) is a question all the same. + */ + settle(paneId: string, status: AgentStatus, now = Date.now()): AgentStatus { + const unbegun = this.notBegun(paneId, status, now) !== null; + return unbegun && (status === "working" || status === "done") ? "idle" : status; + } + + forget(paneId: string): void { this.panes.delete(paneId); } + clear(): void { this.panes.clear(); } +} + +export const launchedAgents = new LaunchedAgents(); diff --git a/server/machine-api.ts b/server/machine-api.ts index 5169898f3..c7259fe62 100644 --- a/server/machine-api.ts +++ b/server/machine-api.ts @@ -6,7 +6,7 @@ import { isJsonObject, jsonResponse } from "./http.ts"; import { crossSiteFileRead } from "./file-view.ts"; const fail = (code: string, message: string, status: number) => jsonResponse({ error: { code, message } }, status); -export const MACHINE_PROXY_PATH = /^(?:session|agents|integrations|pane\/(?:read|scroll|find|selection|conversation(?:\/image|\/tool-output)?|commands|files|omo-tasks|plan|prompt|prompt\/answer|input|keys|close|rename|image|split|zoom|swap|resize|clear|move|focus)|workspace\/(?:create|rename|move|close|directories)|worktree\/(?:create|list|open|remove)|tab\/(?:create|rename|close|move)|layout\/ratio|agent\/rename|plugins\/actions|plugin\/action|fs\/(?:stat|file))$/; +export const MACHINE_PROXY_PATH = /^(?:session|agents|integrations|pane\/(?:read|scroll|find|selection|conversation(?:\/image|\/tool-output)?|commands|files|omo-tasks|plan|prompt|prompt\/answer|input|keys|close|rename|image|split|zoom|swap|resize|clear|focus)|workspace\/(?:create|rename|move|close|directories|diff\/files|diff\/file)|worktree\/(?:create|list|open|remove)|tab\/(?:create|rename|close|move)|layout\/ratio|agent\/rename|plugins\/actions|plugin\/action|fs\/(?:stat|file))$/; /** diff --git a/server/machine-proxy.test.ts b/server/machine-proxy.test.ts index 606072d95..2366b33db 100644 --- a/server/machine-proxy.test.ts +++ b/server/machine-proxy.test.ts @@ -5,6 +5,7 @@ import type { MachineManager } from "./machines.ts"; // A remote bridge that answers like the local conversation route: an ETag, then 304 while unchanged. const asked: (string | null)[] = []; +const diffAsked: { path: string; search: string; authorization: string | null; cookie: string | null; forwarded: string | null }[] = []; const remote = Bun.serve({ port: 0, fetch(request) { @@ -12,6 +13,10 @@ const remote = Bun.serve({ if (path === "/api/pane/conversation/image") return new Response(new Uint8Array([137, 80, 78, 71]), { headers: { "content-type": "image/png" } }); if (path === "/api/pane/conversation/tool-output") return new Response("complete remote output", { headers: { "content-type": "text/plain; charset=utf-8" } }); if (path === "/api/fs/file") return new Response("%PDF-1.7", { headers: { "content-type": "application/pdf" } }); + if (path.startsWith("/api/workspace/diff/")) { + diffAsked.push({ path, search: new URL(request.url).search, authorization: request.headers.get("authorization"), cookie: request.headers.get("cookie"), forwarded: request.headers.get("x-forwarded-for") }); + return Response.json({ files: [], total: 0, truncated: false }, { headers: { "set-cookie": "remote=1" } }); + } const ifNoneMatch = request.headers.get("if-none-match"); asked.push(ifNoneMatch); if (ifNoneMatch === "\"v1\"") return new Response(null, { status: 304, headers: { etag: "\"v1\"" } }); @@ -88,3 +93,28 @@ it("refuses a path with an empty segment instead of forwarding it as another rou } expect(asked.length).toBe(before); }); + +it("forwards a repository diff read with its query and the bridge's own credential, and nothing of the browser's", async () => { + const before = diffAsked.length; + const search = "?pane_id=w1%3Ap1&scope=commit&base=HEAD~1&path=dir%20with%20space%2Fa.txt"; + for (const route of ["files", "file"]) { + const response = await handleMachineRequest(new Request(`http://127.0.0.1/api/machines/pc1/workspace/diff/${route}${search}`, { + headers: { cookie: "herdr_web_token=browser-secret; herdr_web_device=device-secret", authorization: "Bearer browser-secret", "x-forwarded-for": "203.0.113.9" }, + }), manager); + expect(response.status).toBe(200); + expect(response.headers.get("set-cookie")).toBeNull(); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ files: [], total: 0, truncated: false }); + } + expect(diffAsked.slice(before)).toEqual(["files", "file"].map((route) => ({ path: `/api/workspace/diff/${route}`, search, authorization: "Bearer remote-token", cookie: null, forwarded: null }))); + // only the two reads are routes: nothing else under that name reaches the PC + for (const path of ["workspace/diff", "workspace/diff/file/extra", "workspace/diff/apply", "workspace/diffs/file"]) { + const response = await handleMachineRequest(new Request(`http://127.0.0.1/api/machines/pc1/${path}${search}`), manager); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ error: { code: "not_found" } }); + } + const crossSite = await handleMachineRequest(new Request(`http://127.0.0.1/api/machines/pc1/workspace/diff/file${search}`, { headers: { "sec-fetch-site": "cross-site", "sec-fetch-mode": "navigate" } }), manager); + expect(crossSite.status).toBe(403); + expect(await crossSite.json()).toMatchObject({ error: { code: "invalid_origin" } }); + expect(diffAsked.length).toBe(before + 2); +}); diff --git a/server/machine-relay.test.ts b/server/machine-relay.test.ts new file mode 100644 index 000000000..9a751680b --- /dev/null +++ b/server/machine-relay.test.ts @@ -0,0 +1,95 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { Server, ServerWebSocket } from "bun"; +import { MachineRelay, RelayStallClock, upstreamCloseCode } from "./machine-relay.ts"; +import type { MachineManager } from "./machines.ts"; +import { OUTPUT_HARD_BYTES, OUTPUT_HIGH_BYTES, OUTPUT_STALL_MS } from "./output-window.ts"; +import { OUTPUT_STALLED_CLOSE_CODE } from "../shared/terminal-flow.ts"; + +/** Samples every 100 ms like the relay's timer, returning the first close code. */ +function run(clock: RelayStallClock, buffered: (at: number) => number, from: number, until: number): number { + for (let at = from; at <= until; at += 100) { + const code = clock.sample(buffered(at), at); + if (code) return code; + } + return 0; +} + +describe("RelayStallClock", () => { + test("a few KB queued below the high-water mark for 3 s does not close", () => { + expect(run(new RelayStallClock(), () => 8 * 1024, 0, 3000)).toBe(0); + expect(run(new RelayStallClock(), () => OUTPUT_HIGH_BYTES - 1, 0, 3000)).toBe(0); + }); + + test("a stall at the high-water mark for 2 s closes with the explicit 4008, never a reconnect code", () => { + const clock = new RelayStallClock(); + expect(clock.sample(OUTPUT_HIGH_BYTES, 0)).toBe(0); + expect(clock.sample(OUTPUT_HIGH_BYTES + 1, OUTPUT_STALL_MS - 1)).toBe(0); + expect(clock.sample(OUTPUT_HIGH_BYTES + 1, OUTPUT_STALL_MS)).toBe(OUTPUT_STALLED_CLOSE_CODE); + }); + + test("like this PC's rule, one sample below the high-water mark clears the clock", () => { + const clock = new RelayStallClock(); + expect(clock.sample(OUTPUT_HIGH_BYTES, 0)).toBe(0); + expect(clock.sample(OUTPUT_HIGH_BYTES - 1, 100)).toBe(0); + expect(clock.sample(OUTPUT_HIGH_BYTES, 200)).toBe(0); + expect(clock.sample(OUTPUT_HIGH_BYTES, 200 + OUTPUT_STALL_MS - 1)).toBe(0); + expect(clock.sample(OUTPUT_HIGH_BYTES, 200 + OUTPUT_STALL_MS)).toBe(OUTPUT_STALLED_CLOSE_CODE); + }); + + test("buffering past the hard budget still closes at once as an overflow", () => { + expect(new RelayStallClock().sample(OUTPUT_HARD_BYTES + 1, 0)).toBe(OUTPUT_STALLED_CLOSE_CODE); + }); +}); + +describe("upstreamCloseCode", () => { + test("passes an upstream 4008 through unchanged and maps other ends to 1012", () => { + expect(upstreamCloseCode(OUTPUT_STALLED_CLOSE_CODE)).toBe(OUTPUT_STALLED_CLOSE_CODE); + expect(upstreamCloseCode(1006)).toBe(1012); + expect(upstreamCloseCode(1000)).toBe(1012); + }); +}); + +describe("MachineRelay stall timer", () => { + let upstream: Server; + beforeAll(() => { + upstream = Bun.serve({ + port: 0, hostname: "127.0.0.1", + fetch(request, server) { return server.upgrade(request) ? undefined : new Response("no", { status: 400 }); }, + websocket: { message() {} }, + }); + }); + afterAll(() => { upstream.stop(true); }); + + /** A relay bound to a stub phone socket whose queue holds `buffered` bytes; `closes` records its close codes. */ + async function relayWithQueue(buffered: number) { + const manager = { + endpoint: () => ({ url: `http://127.0.0.1:${upstream.port}`, token: "test" }), + trackTerminal: () => () => {}, + } as unknown as MachineManager; + const relay = new MachineRelay(manager, "pc"); + await relay.ready; + const closes: number[] = []; + const client = { + getBufferedAmount: () => buffered, + send: () => 1, + close: (code: number) => { closes.push(code); }, + } as unknown as ServerWebSocket; + relay.bind(client); + return { relay, closes }; + } + + test("a few KB queued at the phone for 3 s does not close the relay", async () => { + const { relay, closes } = await relayWithQueue(8 * 1024); + for (let at = 0; at <= 3000; at += 100) relay.tick(at); + expect(closes).toEqual([]); + relay.close(); + }); + + test("the high-water mark queued at the phone for the stall window closes with 4008", async () => { + const { relay, closes } = await relayWithQueue(OUTPUT_HIGH_BYTES); + for (let at = 0; at < OUTPUT_STALL_MS; at += 100) relay.tick(at); + expect(closes).toEqual([]); + relay.tick(OUTPUT_STALL_MS); + expect(closes).toEqual([OUTPUT_STALLED_CLOSE_CODE]); + }); +}); diff --git a/server/machine-relay.ts b/server/machine-relay.ts index 2313aa30f..672d8dc78 100644 --- a/server/machine-relay.ts +++ b/server/machine-relay.ts @@ -1,9 +1,32 @@ import { authenticatedWebSocket } from "./remote-websocket.ts"; import type { ServerWebSocket } from "bun"; -import { OUTPUT_HARD_BYTES, OUTPUT_STALL_MS } from "./output-window.ts"; +import { OUTPUT_HARD_BYTES, OUTPUT_HIGH_BYTES, OUTPUT_STALL_MS } from "./output-window.ts"; import { OUTPUT_STALLED_CLOSE_CODE } from "../shared/terminal-flow.ts"; import type { MachineManager } from "./machines.ts"; +/** + * The relay's backstop against its own sockets backing up, with this PC's rule (reconcileOutput): + * blocked only while OUTPUT_HIGH_BYTES or more is queued, closed after OUTPUT_STALL_MS of that. + * A slow phone link keeps a few KB queued in userspace while output still moves, so any byte + * must not count. The end-to-end ACK window is enforced by the remote bridge. A stall closes + * with 4008 like this PC's: an overload is never auto-replayed. Returns the close code, or 0. + */ +export class RelayStallClock { + private blockedAt: number | null = null; + + sample(buffered: number, now: number): number { + if (buffered > OUTPUT_HARD_BYTES) return OUTPUT_STALLED_CLOSE_CODE; + if (buffered < OUTPUT_HIGH_BYTES) this.blockedAt = null; + else if (this.blockedAt === null) this.blockedAt = now; + return this.blockedAt !== null && now - this.blockedAt >= OUTPUT_STALL_MS ? OUTPUT_STALLED_CLOSE_CODE : 0; + } +} + +/** The phone sees an upstream 4008 as is (the remote bridge's ACK window stalled); any other end is 1012. */ +export function upstreamCloseCode(code: number): number { + return code === OUTPUT_STALLED_CLOSE_CODE ? code : 1012; +} + /** Relay ACK frames unchanged: only xterm parsing releases remote output credit. */ export class MachineRelay { private upstream: WebSocket; @@ -13,7 +36,7 @@ export class MachineRelay { private closed = false; private untrack: () => void; private timer: ReturnType; - private blockedAt = 0; + private stall = new RelayStallClock(); readonly ready: Promise; constructor(manager: MachineManager, id: string, private readonly readOnly = false) { @@ -39,15 +62,17 @@ export class MachineRelay { else this.waiting.push(raw); } else this.send(raw); }; - this.upstream.onclose = (event) => this.close(event.code === OUTPUT_STALLED_CLOSE_CODE ? event.code : 1012, event.reason || "Remote connection ended"); - this.timer = setInterval(() => { - const buffered = Math.max(this.client?.getBufferedAmount() ?? 0, this.upstream.bufferedAmount); - if (!buffered) this.blockedAt = 0; - else if (!this.blockedAt) this.blockedAt = Date.now(); - if (buffered > OUTPUT_HARD_BYTES || (this.blockedAt && Date.now() - this.blockedAt > OUTPUT_STALL_MS)) this.close(OUTPUT_STALLED_CLOSE_CODE, "terminal transport stalled"); - }, 100); + this.upstream.onclose = (event) => this.close(upstreamCloseCode(event.code), event.reason || "Remote connection ended"); + this.timer = setInterval(() => this.tick(), 100); this.timer.unref(); } + /** One stall-clock sample of both sockets' queues; the 100 ms timer calls it, and tests inject `now`. */ + tick(now = Date.now()): void { + if (this.closed) return; + const buffered = Math.max(this.client?.getBufferedAmount() ?? 0, this.upstream.bufferedAmount); + const code = this.stall.sample(buffered, now); + if (code) this.close(code, "terminal transport stalled"); + } bind(client: ServerWebSocket): void { this.client = client; if (this.closed) { client.close(1012, "Remote connection ended"); return; } diff --git a/server/machines-refresh.test.ts b/server/machines-refresh.test.ts index 84d3d9cd2..d67eca680 100644 --- a/server/machines-refresh.test.ts +++ b/server/machines-refresh.test.ts @@ -61,6 +61,7 @@ async function fixture() { input += chunk.toString(); if (!input.includes("\n")) return; const request = JSON.parse(input.split("\n")[0]!); + if (request.method === "pane.process_info") { socket.end(JSON.stringify({ id: request.id, result: { process_info: { foreground_processes: [] } } }) + "\n"); return; } if (request.method !== "session.snapshot") throw new Error(`unexpected fixture RPC: ${request.method}`); void local.read().then((captured) => socket.end(JSON.stringify({ id: request.id, result: { snapshot: captured } }) + "\n")); }); diff --git a/server/machines.test.ts b/server/machines.test.ts index 6266fac3d..79ec0e595 100644 --- a/server/machines.test.ts +++ b/server/machines.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, win32 } from "node:path"; import { describeProgress } from "../src/lib/bridgeProgress.ts"; import type { SetupJob } from "../shared/machines.ts"; import { paneNotificationTag } from "../shared/notify-policy.ts"; @@ -11,7 +11,7 @@ import { handleMachineRequest, MACHINE_PROXY_PATH } from "./machine-api.ts"; import type { BridgeDescriptor } from "./bridge.ts"; import { createServer } from "./index.ts"; import { MachineManager } from "./machines.ts"; -import { detectHost, psQuote, UNSUPPORTED_HOST, windowsBridgeFiles } from "./remote-host.ts"; +import { detectHost, psQuote, UNSUPPORTED_HOST, windowsBridgeFiles, windowsHost } from "./remote-host.ts"; import { decodeClixml, SshConnection } from "./ssh.ts"; import { terminalAttachSupported } from "./herdr/client.ts"; import { attachableIdentity, isRealNode, sidecarAvailable } from "./pty/sidecar.ts"; @@ -69,9 +69,9 @@ describe("machine boundaries", () => { }); it("proxies only pane/workspace data and never remote management credentials", () => { for (const path of ["pane/focus", "layout/ratio", "tab/move"]) expect(MACHINE_PROXY_PATH.test(path)).toBe(true); - for (const path of ["pane/focus/extra", "layout/apply", "layout/ratio/extra", "layout/../auth"]) expect(MACHINE_PROXY_PATH.test(path)).toBe(false); + for (const path of ["pane/focus/extra", "pane/move", "layout/apply", "layout/ratio/extra", "layout/../auth"]) expect(MACHINE_PROXY_PATH.test(path)).toBe(false); for (const path of ["auth", "push", "updates/install", "machines/setup", "bridge", "../auth", "pane/../../auth", "pane/prompt/answer/extra", "tab/move/extra", "tab/close/extra"]) expect(MACHINE_PROXY_PATH.test(path)).toBe(false); - for (const path of ["session", "agents", "integrations", "pane/files", "pane/find", "pane/image", "pane/prompt/answer", "pane/move", "workspace/create", "tab/create", "tab/rename", "tab/close"]) expect(MACHINE_PROXY_PATH.test(path)).toBe(true); + for (const path of ["session", "agents", "integrations", "pane/files", "pane/find", "pane/image", "pane/prompt/answer", "workspace/create", "tab/create", "tab/rename", "tab/close"]) expect(MACHINE_PROXY_PATH.test(path)).toBe(true); for (const path of ["session", "agents", "integrations", "pane/files", "pane/find", "pane/image", "pane/prompt/answer", "pane/split", "pane/zoom", "pane/swap", "pane/resize", "pane/clear", "workspace/create", "tab/create", "tab/rename", "tab/close"]) expect(MACHINE_PROXY_PATH.test(path)).toBe(true); }); }); @@ -443,7 +443,10 @@ describe("host detection", () => { // the sh probe, then the socket realpath return script.includes("uname") ? "Linux\nx86_64\n/home/u\n/home/u/.config\n/usr/bin/herdr\nbundle-ready\n" : "/home/u/.config/herdr/herdr.sock"; }, - runPowerShell: async () => { if (ps instanceof Error) throw ps; return ps; }, + runPowerShell: async (script: string) => { + if (ps instanceof Error) throw ps; + return script.includes("XDG_CONFIG_HOME") ? ps.replace("AppData\\Roaming\n", "AppData\\Roaming\n\n") : ps; + }, } as unknown as SshConnection); const windowsAnswer = "Windows\nAMD64\nC:\\Users\\u\nC:\\Users\\u\\AppData\\Roaming\nC:\\Users\\u\\AppData\\Local\nC:\\Users\\u\\AppData\\Local\\Programs\\Herdr\\bin\\herdr.exe\n"; @@ -463,6 +466,38 @@ describe("host detection", () => { }); } }); + it("uses Windows XDG_CONFIG_HOME when resolving the herdr socket", async () => { + const scripts: string[] = []; + const ssh = { + run: async () => { throw new Error("Unexpected sh probe"); }, + runPowerShell: async (script: string) => { + scripts.push(script); + return script.includes("XDG_CONFIG_HOME") + ? "Windows\nAMD64\nC:\\Users\\u\nC:\\Users\\u\\AppData\\Roaming\nC:\\Temp\\xdg\nC:\\Users\\u\\AppData\\Local\nC:\\Users\\u\\AppData\\Local\\Programs\\Herdr\\bin\\herdr.exe\n" + : windowsAnswer; + }, + } as unknown as SshConnection; + const { inspection } = await detectHost(ssh, "qa"); + // regression (fails on main): the inspection must read XDG_CONFIG_HOME and prefer it over APPDATA + expect(scripts.some((script) => script.includes("$env:XDG_CONFIG_HOME"))).toBe(true); + expect(inspection.expectedSocket).toBe("C:\\Temp\\xdg\\herdr\\sessions\\qa\\herdr.sock"); + }); + it("keeps the APPDATA field aligned when Windows XDG_CONFIG_HOME is blank", async () => { + const answer = "Windows\nAMD64\nC:\\Users\\u\nC:\\Users\\u\\AppData\\Roaming\n\nC:\\Users\\u\\AppData\\Local\nC:\\Users\\u\\AppData\\Local\\Programs\\Herdr\\bin\\herdr.exe\n"; + const ssh = { + run: async () => { throw new Error("'sh' is not recognized as an internal or external command"); }, + runPowerShell: async (script: string) => script.includes("XDG_CONFIG_HOME") ? answer : windowsAnswer, + } as unknown as SshConnection; + const { inspection } = await detectHost(ssh, "qa"); + // control: the empty XDG line preserves APPDATA, LOCALAPPDATA and herdr field positions + expect(inspection.expectedSocket).toBe("C:\\Users\\u\\AppData\\Roaming\\herdr\\sessions\\qa\\herdr.sock"); + expect(inspection.herdrPath).toBe("C:\\Users\\u\\AppData\\Local\\Programs\\Herdr\\bin\\herdr.exe"); + }); + it("uses Windows APPDATA when XDG_CONFIG_HOME is absent", async () => { + const { inspection } = await detectHost(pc("'sh' is not recognized as an internal or external command", windowsAnswer), "qa"); + // control: the existing APPDATA fallback is unchanged + expect(inspection.expectedSocket).toBe("C:\\Users\\u\\AppData\\Roaming\\herdr\\sessions\\qa\\herdr.sock"); + }); it("refuses a Windows PC that is not x64, by name", async () => { await expect(detectHost(pc("sh: not found", windowsAnswer.replace("AMD64", "ARM64")))).rejects.toThrow(UNSUPPORTED_HOST); }); @@ -527,6 +562,58 @@ describe("bridge verification failures", () => { return verify(ssh, descriptor, socket).then(() => "verified", (error: Error) => error.message); } + // Evaluate the entrypoint's actual socket setup without starting its daemon. + const entryLines = readFileSync(new URL("./remote-entry.ts", import.meta.url), "utf8").split("\n"); + const socketSetup = ["const herdrConfig =", "const session =", 'process.env["HERDR_SOCKET"] ='].map((prefix) => { + const line = entryLines.find((line) => line.startsWith(prefix)); + if (!line) throw new Error("remote-entry socket setup was not found"); + return line; + }).join("\n"); + const bridgeSocket = new Function("windows", "process", "join", "homedir", `${socketSetup}\nreturn process.env["HERDR_SOCKET"];`); + + for (const root of ["C:\\Temp\\xdg\\", "C:\\", "C:/Temp/xdg", "C:\\Temp\\xdg\\..\\config"]) { + for (const session of ["qa", undefined]) { + it(`connects the inspected Windows socket to its bridge for ${JSON.stringify(root)}, session ${session}`, async () => { + const env = { XDG_CONFIG_HOME: root, APPDATA: "C:\\Users\\u\\AppData\\Roaming", HERDR_REMOTE_SESSION: session }; + const socket = String(bridgeSocket(true, { env }, win32.join, () => "C:\\Users\\u")); + const identity = { pid: process.pid, managed_remote: true, bridge_protocol: BRIDGE_PROTOCOL, bundle_version: REMOTE_BUNDLE_VERSION, socket_path: socket, socket_id: "1:2", herdr: { protocol: 22 } }; + const port = answering(200, JSON.stringify(identity)); + const descriptor = { ...identity, port, token: TOKEN }; + const ssh = { + run: async () => { throw new Error("'sh' is not recognized as an internal or external command"); }, + runPowerShell: async (script: string) => { + if (script.includes("PROCESSOR_ARCHITECTURE")) return `Windows\nAMD64\nC:\\Users\\u\n${env.APPDATA}\n${root}\nC:\\Users\\u\\AppData\\Local\nC:\\Programs\\herdr.exe\n${JSON.stringify(descriptor)}\n`; + if (script.includes("--version")) return "herdr 0.9.3"; + if (script.includes("Get-Process")) return "live"; + throw new Error("Unexpected remote mutation"); + }, + forward: async (local: number, remote: number) => { + const relay = Bun.serve({ port: local, hostname: "127.0.0.1", fetch: (request) => fetch(`http://127.0.0.1:${remote}${new URL(request.url).pathname}`, { headers: request.headers }) }); + stops.push(() => relay.stop(true)); + }, + } as unknown as SshConnection; + const { inspection } = await detectHost(ssh, session); + expect(inspection.expectedSocket).toBe(socket); + const manager = new MachineManager(temp(), {} as PushService, {} as CompletionTracker); + stops.push(() => manager.stop()); + const runtime = { generation: 0, ssh, machine: { target: { destination: "pc", session } }, endpoint: undefined }; + const prepare = (manager as unknown as { prepare(runtime: unknown): Promise }).prepare.bind(manager); + await prepare(runtime); + expect(runtime.endpoint).toMatchObject({ token: TOKEN }); + }); + } + } + + for (const root of ["relative\\xdg", "C:xdg", "/Temp/xdg"]) { + it(`rejects a Windows XDG root without a drive-absolute path: ${root}`, async () => { + const ssh = { + run: async () => { throw new Error("'sh' is not recognized as an internal or external command"); }, + runPowerShell: async () => `Windows\nAMD64\nC:\\Users\\u\nC:\\Users\\u\\AppData\\Roaming\n${root}\nC:\\Users\\u\\AppData\\Local\nC:\\Programs\\herdr.exe\n`, + } as unknown as SshConnection; + await expect(windowsHost.inspect(ssh)).rejects.toThrow("XDG_CONFIG_HOME"); + }); + } + it("says herdr is not running when the bridge finds no socket", async () => { const dir = temp(); const socket = join(dir, "herdr.sock"); diff --git a/server/omo-identity.test.ts b/server/omo-identity.test.ts new file mode 100644 index 000000000..26f505a73 --- /dev/null +++ b/server/omo-identity.test.ts @@ -0,0 +1,219 @@ +import { expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, readlinkSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { HerdrPane } from "../shared/protocol.ts"; +import { heldRuntime, omoProcessIdentity, omoSessionFolder, omoTranscriptsOfCwd, selectOmoTranscript } from "./omo.ts"; +import { processStartedAt } from "./process-start.ts"; + +const holdRecord = (dir: string, id: string, pid: number, record: object): void => { + const holders = join(dir, "session-holders", encodeURIComponent(id)); + mkdirSync(holders, { recursive: true }); + writeFileSync(join(holders, `${pid}.json`), JSON.stringify(record)); +}; +const writeSession = (dir: string, id: string, cwd: string): string => { + mkdirSync(dir, { recursive: true }); + const path = join(dir, `2026-10-02T16-47-10-905Z_${id}.jsonl`); + writeFileSync(path, JSON.stringify({ type: "session", id, cwd, timestamp: "2026-10-02T16:47:10.905Z" }) + "\n"); + return path; +}; +const info = (processes: { pid: number; argv?: string[]; cwd?: string }[]) => ({ process_info: { foreground_processes: processes } }); +const NEUTRAL = ["/opt/team-ai/bin/run"]; + +it.skipIf(process.platform !== "linux")("prefers the OS cwd over stale pane directories for a held custom session", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-omo-proc-cwd-"))); + try { + const home = join(root, "home"); + const pid = process.pid; + const cwd = readlinkSync(`/proc/${pid}/cwd`); + const agentDir = join(root, "agent"); + const dir = join(agentDir, "sessions", omoSessionFolder(cwd)); + holdRecord(dir, "held", pid, { pid, processStartedAtMs: processStartedAt(pid), cwd }); + const written = writeSession(dir, "held", cwd); + writeSession(dir, "unheld", cwd); + const pane = { pane_id: "w:p1", cwd: join(root, "stale-pane"), foreground_cwd: join(root, "stale-foreground") } as HerdrPane; + const infos = new Map([[pane.pane_id, info([{ pid, argv: NEUTRAL }])]]); + const resolved = omoTranscriptsOfCwd(cwd, [pane], infos, home, () => [`OMO_CODING_AGENT_DIR=${agentDir}`]).get(pane.pane_id); + expect(resolved?.path).toBe(realpathSync(written)); + expect(resolved?.pending).toBeNull(); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("keeps a held claim's store when its own file cannot be read as a candidate", () => { + // A custom holder in store A names sess-x; the pane's other, legacy process resolves + // the default store B, where another pane's session happens to share the id. + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-omo-scope-"))); + try { + const home = join(root, "home"); + const cwd = join(root, "project"); + const storeA = join(root, "a", "agent"); + const dirA = join(storeA, "sessions", omoSessionFolder(cwd)); + const dirB = join(home, ".omo", "agent", "sessions", omoSessionFolder(cwd)); + const pid = process.pid; + holdRecord(dirA, "sess-x", pid, { pid, processStartedAtMs: processStartedAt(pid), cwd }); + // the held session's own file is present in A but unreadable as a candidate + writeFileSync(join(dirA, "2026-10-02T16-47-10-905Z_sess-x.jsonl"), "{"); + writeSession(dirB, "sess-x", cwd); + const pane = { pane_id: "w:p1", cwd } as HerdrPane; + const infos = new Map([[pane.pane_id, info([{ pid, argv: NEUTRAL, cwd }, { pid: process.ppid, argv: ["node", "/opt/omo/bin/omo.js"], cwd }])]]); + const envOf = (pid: number) => pid === process.pid ? [`OMO_CODING_AGENT_DIR=${storeA}`] : ["PATH=/bin"]; + const out = omoTranscriptsOfCwd(cwd, [pane], infos, home, envOf).get(pane.pane_id); + // the claim still binds store A: B's same-id file is another session, and the pane + // reports nothing — not the other store's transcript + expect(out?.path).toBeNull(); + expect(out?.pending).toBeNull(); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("keeps a truly pending held id pending while another store has the id written", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-omo-pending-scope-"))); + try { + const home = join(root, "home"); + const cwd = join(root, "project"); + const storeA = join(root, "a", "agent"); + const dirA = join(storeA, "sessions", omoSessionFolder(cwd)); + const dirB = join(home, ".omo", "agent", "sessions", omoSessionFolder(cwd)); + const pid = process.pid; + holdRecord(dirA, "sess-x", pid, { pid, processStartedAtMs: processStartedAt(pid), cwd }); + writeSession(dirB, "sess-x", cwd); + const pane = { pane_id: "w:p1", cwd } as HerdrPane; + const infos = new Map([[pane.pane_id, info([{ pid, argv: NEUTRAL, cwd }, { pid: process.ppid, argv: ["node", "/opt/omo/bin/omo.js"], cwd }])]]); + const envOf = (pid: number) => pid === process.pid ? [`OMO_CODING_AGENT_DIR=${storeA}`] : ["PATH=/bin"]; + const out = omoTranscriptsOfCwd(cwd, [pane], infos, home, envOf).get(pane.pane_id); + expect(out?.path).toBeNull(); + expect(out?.pending).toBe("sess-x"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("binds a structured claim to its store and a legacy string claim to none", () => { + const a = { path: "/a/s.jsonl", id: "same", createdAt: 100, store: "/store-a" }; + const b = { path: "/b/s.jsonl", id: "same", createdAt: 100, store: "/store-b" }; + const runtime = { paneId: "p", startedAt: 10, paths: [] as string[], ids: [] as string[] }; + // a scoped claim selects its own store's file, and still selects it when the pane + // owns both stores: the other store's same-id file never counts + const scoped = heldRuntime(runtime, [{ id: "same", store: "/store-b" }], [a, b]); + expect(selectOmoTranscript("p", [a, b], [scoped])).toBe("/b/s.jsonl"); + expect(selectOmoTranscript("p", [a], [heldRuntime(runtime, [{ id: "same", store: "/store-b" }], [a])])).toBeNull(); + // a claim whose file is gone still binds: it never resolves to another store's file + expect(selectOmoTranscript("p", [a], [heldRuntime(runtime, [{ id: "same", store: "/store-b" }], [a, b])])).toBeNull(); + // legacy callers that know no store keep matching by id alone + const unscoped = heldRuntime(runtime, ["same"], [a]); + expect(selectOmoTranscript("p", [a], [unscoped])).toBe("/a/s.jsonl"); +}); + +it("fails closed when one pane holds two different sessions", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-omo-ambiguous-"))); + try { + const home = join(root, "home"); + const cwd = join(root, "project"); + const storeA = join(root, "a", "agent"); + const dirA = join(storeA, "sessions", omoSessionFolder(cwd)); + const pid = process.pid; + holdRecord(dirA, "sess-1", pid, { pid, processStartedAtMs: processStartedAt(pid), cwd }); + holdRecord(dirA, "sess-2", pid, { pid, processStartedAtMs: processStartedAt(pid), cwd }); + writeSession(dirA, "sess-1", cwd); + writeSession(dirA, "sess-2", cwd); + const pane = { pane_id: "w:p1", cwd } as HerdrPane; + const infos = new Map([[pane.pane_id, info([{ pid, argv: NEUTRAL, cwd }])]]); + const out = omoTranscriptsOfCwd(cwd, [pane], infos, home, () => [`OMO_CODING_AGENT_DIR=${storeA}`]).get(pane.pane_id); + expect(out?.path).toBeNull(); + expect(out?.pending).toBeNull(); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("lets an ambiguous peer's claims still block a pane claiming a known file", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-omo-peer-block-"))); + try { + const home = join(root, "home"); + const cwd = join(root, "project"); + const storeA = join(root, "a", "agent"); + const dirA = join(storeA, "sessions", omoSessionFolder(cwd)); + const pidA = process.pid; + const pidB = process.ppid; + holdRecord(dirA, "shared", pidA, { pid: pidA, processStartedAtMs: processStartedAt(pidA), cwd }); + holdRecord(dirA, "shared", pidB, { pid: pidB, processStartedAtMs: processStartedAt(pidB), cwd }); + holdRecord(dirA, "other", pidB, { pid: pidB, processStartedAtMs: processStartedAt(pidB), cwd }); + writeSession(dirA, "shared", cwd); + writeSession(dirA, "other", cwd); + const panes = [{ pane_id: "w:p1", cwd } as HerdrPane, { pane_id: "w:p2", cwd } as HerdrPane]; + const infos = new Map([ + ["w:p1", info([{ pid: pidA, argv: NEUTRAL, cwd }])], + ["w:p2", info([{ pid: pidB, argv: NEUTRAL, cwd }])], + ]); + const out = omoTranscriptsOfCwd(cwd, panes, infos, home, () => [`OMO_CODING_AGENT_DIR=${storeA}`]); + // p2 is ambiguous and selects nothing; its claims still block p1's known file + expect(out.get("w:p1")?.path).toBeNull(); + expect(out.get("w:p2")?.path).toBeNull(); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("rejects a holder the pane's cwd names when the process runs elsewhere", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-omo-cwd-win-"))); + try { + const home = join(root, "home"); + const cwd = join(root, "project"); + const other = join(root, "other"); + const storeA = join(root, "a", "agent"); + const dirA = join(storeA, "sessions", omoSessionFolder(cwd)); + const pid = process.pid; + const env = [`OMO_CODING_AGENT_DIR=${storeA}`]; + // a stale record under the pane's cwd names this pid, but the process runs in + // /other: the process's own cwd wins and the pane-scoped holder is not its + holdRecord(dirA, "stale", pid, { pid, processStartedAtMs: processStartedAt(pid), cwd }); + expect(omoProcessIdentity({ pid, argv: NEUTRAL, cwd: other }, cwd, home, () => env)).toBeNull(); + // once the record names where the process actually runs, it is recognized + holdRecord(join(storeA, "sessions", omoSessionFolder(other)), "moved", pid, { pid, processStartedAtMs: processStartedAt(pid), cwd: other }); + expect(omoProcessIdentity({ pid, argv: NEUTRAL, cwd: other }, cwd, home, () => env)) + .toEqual({ agentDir: storeA, heldIds: ["moved"], custom: true }); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("binds the folder a moved process runs in and resolves the session it holds there", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-omo-moved-"))); + try { + const home = join(root, "home"); + const cwd = join(root, "project"); + const other = join(root, "other"); + const storeA = join(root, "a", "agent"); + const dirOther = join(storeA, "sessions", omoSessionFolder(other)); + const pid = process.pid; + holdRecord(dirOther, "moved", pid, { pid, processStartedAtMs: processStartedAt(pid), cwd: other }); + const file = writeSession(dirOther, "moved", other); + const env = () => [`OMO_CODING_AGENT_DIR=${storeA}`]; + // the pane's cwd is stale; herdr reports the process's own cwd + const pane = { pane_id: "w:p1", cwd } as HerdrPane; + const infos = new Map([[pane.pane_id, info([{ pid, argv: NEUTRAL, cwd: other }])]]); + expect(omoTranscriptsOfCwd(other, [pane], infos, home, env).get(pane.pane_id)?.path).toBe(realpathSync(file)); + // the folder the pane left behind claims nothing of it + expect(omoTranscriptsOfCwd(cwd, [pane], infos, home, env).has(pane.pane_id)).toBeFalse(); + // a foreground cwd herdr reports for the pane reaches the same answer + const moved = { pane_id: "w:p1", cwd, foreground_cwd: other } as HerdrPane; + const processCwd = new Map([[moved.pane_id, info([{ pid, argv: NEUTRAL, cwd: other }])]]); + expect(omoTranscriptsOfCwd(other, [moved], processCwd, home, env).get(moved.pane_id)?.path).toBe(realpathSync(file)); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("still rejects conflicting claims among peers that moved to one cwd", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-omo-moved-peers-"))); + try { + const home = join(root, "home"); + const cwd = join(root, "project"); + const other = join(root, "other"); + const storeA = join(root, "a", "agent"); + const dirOther = join(storeA, "sessions", omoSessionFolder(other)); + const pidA = process.pid; + const pidB = process.ppid; + holdRecord(dirOther, "shared", pidA, { pid: pidA, processStartedAtMs: processStartedAt(pidA), cwd: other }); + holdRecord(dirOther, "shared", pidB, { pid: pidB, processStartedAtMs: processStartedAt(pidB), cwd: other }); + writeSession(dirOther, "shared", other); + // one pane kept its old pane cwd; its process runs in the other's folder + const panes = [{ pane_id: "w:p1", cwd } as HerdrPane, { pane_id: "w:p2", cwd: other } as HerdrPane]; + const infos = new Map([ + ["w:p1", info([{ pid: pidA, argv: NEUTRAL, cwd: other }])], + ["w:p2", info([{ pid: pidB, argv: NEUTRAL, cwd: other }])], + ]); + const out = omoTranscriptsOfCwd(other, panes, infos, home, () => [`OMO_CODING_AGENT_DIR=${storeA}`]); + expect(out.get("w:p1")?.path).toBeNull(); + expect(out.get("w:p2")?.path).toBeNull(); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/server/omo-status.ts b/server/omo-status.ts index 3aef577e3..85495f2f3 100644 --- a/server/omo-status.ts +++ b/server/omo-status.ts @@ -158,6 +158,21 @@ export interface OmoStatusDeps { onChange: (paneId: string, status: AgentStatus, background: number, turn: boolean, rest?: OmoRest) => void; /** a pane was found to run OmO: what herdr called it until now is the same agent */ onFound?: (paneId: string) => void; + /** the identity the panes' status is reported under (`omo`), and the names herdr gave the agent before */ + agent?: string; + aliases?: readonly string[]; + /** a session record applied to the turn so far (`omoTurnAfter`): another agent's records have their own */ + turnAfter?: (turn: OmoTurn, line: OmoLine) => OmoTurn; + /** + * Whether the pane's own status may replace herdr's (default: always, once the session file + * is known). GJC withholds it until a turn record was read and the read is current: + * `available` says the file could be opened at the last poll, `complete` that everything it + * held was read to a record's end. An empty, gone or half-written transcript is nobody's + * rest, and an idle that cannot be shown is not the pane's. + */ + ready?: (tracked: { status: OmoPaneStatus; turn: OmoTurn; available: boolean; complete: boolean }) => boolean; + /** a turn the process holding the file now could not have run is an interruption told as a failed rest (GJC); default: a plain rest (OmO) */ + staleIsFailure?: boolean; file?: OmoFile; background?: (cwd: string) => Map; /** how often the session files are looked at, and the panes found anew */ @@ -166,7 +181,7 @@ export interface OmoStatusDeps { now?: () => number; } -interface Tracked extends OmoPane { cwd: string; offset: number; size: number; id: string; /** found again after a while herdr's status stood for it: a turn still running is told anew */ retell: boolean; turn: OmoTurn; status: OmoPaneStatus; /** at rest after an error, and when that error was written */ failed: boolean; failedAt: number | null; /** what is read now is the file's history, found anew: not written since it was being read */ history: boolean; background: number } +interface Tracked extends OmoPane { cwd: string; offset: number; size: number; id: string; /** found again after a while herdr's status stood for it: a turn still running is told anew */ retell: boolean; turn: OmoTurn; status: OmoPaneStatus; /** at rest after an error, and when that error was written */ failed: boolean; failedAt: number | null; /** what is read now is the file's history, found anew: not written since it was being read */ history: boolean; background: number; /** the file could be opened at the last poll, and all it held was read to a record's end */ available: boolean; complete: boolean; /** a poll has considered this pane: a baseline it then read is nobody's news */ polled: boolean; /** the pane's own status stood for it at the last poll */ authoritative: boolean } const FILES: OmoFile = { stat: (path) => { try { const fd = openSync(path, "r"); try { const stat = fstatSync(fd); return { size: stat.size, id: `${stat.dev}:${stat.ino}` }; } finally { closeSync(fd); } } catch { return null; } }, @@ -196,12 +211,22 @@ export class OmoStatus { private readonly background: NonNullable; private readonly now: () => number; private readonly refreshMs: number; + private readonly agent: string; + private readonly aliases: readonly string[]; + private readonly turnAfter: NonNullable; + private readonly ready: NonNullable; + private readonly staleIsFailure: boolean; constructor(private readonly deps: OmoStatusDeps) { this.file = deps.file ?? FILES; this.background = deps.background ?? omoBackgroundTasks; this.now = deps.now ?? Date.now; this.refreshMs = deps.refreshMs ?? 5000; + this.agent = deps.agent ?? "omo"; + this.aliases = deps.aliases ?? OMO_ALIASES; + this.turnAfter = deps.turnAfter ?? omoTurnAfter; + this.ready = deps.ready ?? (() => true); + this.staleIsFailure = deps.staleIsFailure ?? false; } /** Watches with no browser connected too: web push depends on it. */ @@ -221,9 +246,15 @@ export class OmoStatus { this.timer = null; } + /** the pane's own status may replace herdr's only while it is allowed to (omo: once the session is known) */ + private authoritative(tracked: Tracked): boolean { + return tracked.path !== null && this.ready(tracked); + } + /** herdr's status for this pane is replaced: its events say nothing true. */ tracks(paneId: string): boolean { - return this.panes.get(paneId)?.path != null; + const tracked = this.panes.get(paneId); + return tracked !== undefined && this.authoritative(tracked); } /** The pane runs OmO, its session known or not: its identity is `omo` whatever herdr calls it. */ @@ -276,7 +307,7 @@ export class OmoStatus { const startedAt = pane.startedAt ?? before?.startedAt ?? (prior?.path === pane.path ? prior.startedAt : null); // an error the pane rested on is the same session's alone: another one's answers finish nothing of it const same = prior !== undefined && prior.path === pane.path; - this.panes.set(paneId, { ...pane, startedAt, cwd: cwds.get(paneId) ?? "", offset: -1, size: -1, id: "", retell: prior !== undefined, turn: noTurn(), status: prior?.status ?? "idle", failed: same && prior.failed, failedAt: same ? prior.failedAt : null, history: false, background: prior?.background ?? 0 }); + this.panes.set(paneId, { ...pane, startedAt, cwd: cwds.get(paneId) ?? "", offset: -1, size: -1, id: "", retell: prior !== undefined, turn: noTurn(), status: prior?.status ?? "idle", failed: same && prior.failed, failedAt: same ? prior.failedAt : null, history: false, background: prior?.background ?? 0, available: false, complete: false, polled: this.deps.ready !== undefined && (before?.polled === true || prior !== undefined), authoritative: false }); } if (pane.path !== null) this.last.delete(paneId); } @@ -298,7 +329,7 @@ export class OmoStatus { * OmO's until now: what OmO did there is then no part of the other agent's status. */ named(paneId: string, agent: string | null): boolean { - if (agent === null || agent === "omo" || OMO_ALIASES.includes(agent)) return false; + if (agent === null || agent === this.agent || this.aliases.includes(agent)) return false; const held = this.panes.delete(paneId); const remembered = this.last.delete(paneId); if (!held && !remembered) return false; @@ -314,23 +345,26 @@ export class OmoStatus { poll(): void { const counts = new Map>(); for (const [paneId, tracked] of this.panes) { - if (tracked.path === null) continue; + if (tracked.path === null) { this.noteAuthority(tracked); continue; } const stat = this.file.stat(tracked.path); - if (stat === null) continue; + tracked.available = stat !== null; + if (stat === null) { tracked.complete = false; this.noteAuthority(tracked); continue; } const { size } = stat; if (tracked.offset === -1 || size < tracked.offset || stat.id !== tracked.id) this.readFrom(tracked, tracked.path, size); // also what a read that failed left unread - else if (size !== tracked.size || tracked.offset < size) tracked.offset = this.file.lines(tracked.path, tracked.offset, size, (line) => { tracked.turn = omoTurnAfter(tracked.turn, line); }); + else if (size !== tracked.size || tracked.offset < size) tracked.offset = this.file.lines(tracked.path, tracked.offset, size, (line) => { tracked.turn = this.turnAfter(tracked.turn, line); }); tracked.size = size; tracked.id = stat.id; + tracked.complete = tracked.offset >= size; // nothing could be read of it (a read that failed): what the pane read before stands until one succeeds - if (tracked.turn.status === null && tracked.offset < size) continue; + if (tracked.turn.status === null && tracked.offset < size) { this.noteAuthority(tracked); continue; } const stale = tracked.turn.status === "working" && tracked.turn.at !== null && tracked.startedAt !== null && tracked.turn.at < tracked.startedAt - STALE_TURN_MS; + if (this.staleIsFailure && stale && tracked.turn.asks.length > 0) tracked.turn = { ...tracked.turn, asks: [] }; const status: OmoPaneStatus = tracked.turn.asks.length > 0 ? "blocked" : tracked.turn.status === "working" && !stale ? "working" : "idle"; const sessionId = omoSessionId(tracked.path); if (!counts.has(tracked.cwd)) counts.set(tracked.cwd, tracked.cwd ? this.background(tracked.cwd) : new Map()); const background = sessionId ? counts.get(tracked.cwd)!.get(sessionId) ?? 0 : 0; - const failed = status === "idle" && tracked.turn.failed === true; + const failed = status === "idle" && (tracked.turn.failed === true || (this.staleIsFailure && stale)); // an answer written after the error that ended the turn (a retry's, or one to a new prompt): // the pane worked meanwhile, though it read READY, and this rest is the finish of that work. // Without the times to tell it newer, it is not taken for one: a finish missed, never made up @@ -346,16 +380,32 @@ export class OmoStatus { tracked.failedAt = failed ? tracked.turn.at : null; tracked.history = false; tracked.background = background; - if (changed) this.deps.onChange(paneId, status, background, turn, rest); + // a pane whose file said nothing while it was polled had herdr's word stand for it: + // its own status, once it holds again, is told so that last word does not keep standing + const regained = this.noteAuthority(tracked); + if ((changed || regained) && this.ready(tracked)) this.deps.onChange(paneId, status, background, turn || regained, rest); } } + /** + * What this poll found the pane's authority to be. True when it returns to a pane that was + * polled without it: its own status must then be told, though it did not change — the raw + * word that stood for the pane in between is the one a listener holds. + */ + private noteAuthority(tracked: Tracked): boolean { + const authoritative = this.authoritative(tracked); + const regained = tracked.polled && authoritative && !tracked.authoritative; + tracked.authoritative = authoritative; + tracked.polled = true; + return regained; + } + /** Replay from the start once, then consume only appended complete records. */ private readFrom(tracked: Tracked, path: string, size: number): void { tracked.turn = noTurn(); tracked.history = true; tracked.offset = this.file.lines(path, 0, size, (line) => { - tracked.turn = omoTurnAfter(tracked.turn, line); + tracked.turn = this.turnAfter(tracked.turn, line); }); } @@ -368,7 +418,7 @@ export class OmoStatus { const own =

(entry: P): P => { const tracked = this.panes.get(entry.pane_id); if (!tracked) return entry; - return { ...entry, agent: "omo", ...(tracked.path !== null ? { agent_status: tracked.status } : {}) }; + return { ...entry, agent: this.agent, ...(this.authoritative(tracked) ? { agent_status: tracked.status } : {}) }; }; return { ...snapshot, panes: snapshot.panes.map(own), ...(snapshot.agents ? { agents: snapshot.agents.map(own) } : {}) }; } diff --git a/server/omo.contract.test.ts b/server/omo.contract.test.ts index e07637bf1..0e5bdb35c 100644 --- a/server/omo.contract.test.ts +++ b/server/omo.contract.test.ts @@ -3,8 +3,8 @@ import { mkdirSync, mkdtempSync, readdirSync, readlinkSync, realpathSync, rmSync import { tmpdir } from "node:os"; import { join } from "node:path"; import { herdrRpc, sessionSnapshot, workspaceClose, workspaceCreate } from "./herdr/client.ts"; -import { labelOmoPanes } from "./conversation.ts"; -import { isOmoProcess, omoSessionForPane, omoTranscriptForPane } from "./omo.ts"; +import { labelOmoPanes, paneTranscript } from "./conversation.ts"; +import { isOmoProcess, omoPanes, omoSessionForPane, omoTranscriptForPane, omoTranscriptsOfCwd, type ProcessInfo } from "./omo.ts"; import { createServer } from "./index.ts"; import type { ConversationResponse } from "../shared/protocol.ts"; import { startShellAgent } from "./shell-agent.ts"; @@ -21,19 +21,30 @@ afterAll(async () => { rmSync(root, { recursive: true, force: true }); }); -async function pane(id?: string): Promise { - const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-omo-binding" }); - workspaces.push(created.workspace.workspace_id); - const paneId = created.root_pane.pane_id; - await herdrRpc("pane.send_text", { pane_id: paneId, text: `${process.execPath} ${script}${id ? ` --session-id ${id}` : ""}\n` }); - for (let attempt = 0; attempt < 100; attempt++) { - const info = await herdrRpc<{ process_info?: { foreground_processes?: { argv?: string[] }[] } }>("pane.process_info", { pane_id: paneId }); +async function waitForProcess(paneId: string, marker: string) { + const deadline = Date.now() + 5_000; + for (;;) { + const info = await herdrRpc>("pane.process_info", { pane_id: paneId }); + const processes = info.process_info?.foreground_processes ?? []; // the stand-in itself, by its path: the shell's own startup processes come and go before it - if (info.process_info?.foreground_processes?.some((process) => process.argv?.includes(script))) return paneId; + const process = processes.find((process) => process.argv?.includes(marker)); + if (process) return { process, processes }; + if (Date.now() >= deadline) { + throw new Error(`test process ${marker} did not appear in pane ${paneId}; last process info: ${JSON.stringify(info)}`); + } await Bun.sleep(50); } - throw new Error("test omo process did not start"); } + +async function launch(command: string, marker: string): Promise { + const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-omo-binding" }); + workspaces.push(created.workspace.workspace_id); + const paneId = created.root_pane.pane_id; + await herdrRpc("pane.send_text", { pane_id: paneId, text: `${command}\n` }); + await waitForProcess(paneId, marker); + return paneId; +} +const pane = (id?: string) => launch(`${process.execPath} ${script}${id ? ` --session-id ${id}` : ""}`, script); const read = async (paneId: string) => omoTranscriptForPane(paneId, root, (await sessionSnapshot()).panes, root); const session = (id: string, timestamp = new Date().toISOString()) => { const path = join(dir, `${id}.jsonl`); @@ -47,7 +58,15 @@ it("labels a running omo pane when herdr supplies no agent kind", async () => { try { expect(snapshot.panes.find((entry) => entry.pane_id === paneId)?.agent).toBeUndefined(); - const labelled = await labelOmoPanes(snapshot); + // The fixture's process marker and the label probe are separate herdr reads. + // Wait for the label itself, keeping the original unlabelled snapshot for both checks. + const deadline = Date.now() + 5_000; + let labelled = await labelOmoPanes(snapshot); + while (labelled.panes.find((entry) => entry.pane_id === paneId)?.agent !== "omo") { + if (Date.now() >= deadline) throw new Error("test omo process was not labelled"); + await Bun.sleep(50); + labelled = await labelOmoPanes(snapshot); + } expect(labelled.panes.find((entry) => entry.pane_id === paneId)?.agent).toBe("omo"); expect(snapshot.panes.find((entry) => entry.pane_id === paneId)?.agent).toBeUndefined(); @@ -103,10 +122,10 @@ it("starts omo through the pane's shell and waits until omo is its foreground pr }); // omo keeps no descriptor on its session file; it publishes a holder record instead -const hold = async (paneId: string, id: string) => { - const info = await herdrRpc<{ process_info?: { foreground_processes?: { pid: number; argv?: string[] }[] } }>("pane.process_info", { pane_id: paneId }); - const pid = info.process_info!.foreground_processes!.find((process) => isOmoProcess(process.argv ?? []))!.pid; - const holders = join(dir, "session-holders", encodeURIComponent(id)); +const hold = async (paneId: string, id: string, store = dir, marker = script) => { + // A later process-info snapshot can omit the launcher even after launch() observed it. + const { process: { pid } } = await waitForProcess(paneId, marker); + const holders = join(store, "session-holders", encodeURIComponent(id)); mkdirSync(holders, { recursive: true }); writeFileSync(join(holders, `${pid}.json`), JSON.stringify({ pid, bootAtMs: 0, processStartedAtMs: Math.floor(processStartedAt(pid)! / 1000) * 1000, cwd: root })); }; @@ -208,3 +227,55 @@ it.skipIf(process.platform !== "linux")("ignores the background task logs omo ho rmSync(cwd, { recursive: true, force: true }); } }); + +// a custom launcher's own name says nothing about omo, and a process's environment at +// exec is read from /proc//environ: both halves of that contract are Linux-only +it.skipIf(process.platform !== "linux")("recognizes a custom launcher only by verified holders in an env-named store", async () => { + const launcher = join(root, "team-ai"); + writeFileSync(launcher, "setInterval(() => {}, 1000);\n"); + const agentDir = join(root, "custom-agent"); + const store = join(agentDir, "sessions", `-${root.replaceAll("/", "-")}--`); + const paneId = await launch(`OMO_CODING_AGENT_DIR=${agentDir} ${process.execPath} ${launcher}`, launcher); + // the environment entry alone is no identity: nothing is held yet + expect(await omoSessionForPane(paneId, root, (await sessionSnapshot()).panes, root)).toEqual({ path: null, pending: null }); + expect((await labelOmoPanes(await sessionSnapshot())).panes.find((pane) => pane.pane_id === paneId)?.agent).toBeUndefined(); + // a held session omo has not written yet is pending, never another store's file + await hold(paneId, "custom-pending", store, launcher); + expect(await omoSessionForPane(paneId, root, (await sessionSnapshot()).panes, root)).toEqual({ path: null, pending: "custom-pending" }); + expect((await labelOmoPanes(await sessionSnapshot())).panes.find((pane) => pane.pane_id === paneId)?.agent).toBe("omo"); + const written = join(store, "2026-10-03T00-00-00-000Z_custom-pending.jsonl"); + writeFileSync(written, JSON.stringify({ type: "session", id: "custom-pending", cwd: root, timestamp: new Date().toISOString() }) + "\n"); + expect(await omoTranscriptForPane(paneId, root, (await sessionSnapshot()).panes, root)).toBe(realpathSync(written)); + const snapshot = await sessionSnapshot(); + const { process: custom, processes } = await waitForProcess(paneId, launcher); + expect(readlinkSync(`/proc/${custom.pid}/cwd`)).toBe(root); + // Older process-info responses omit cwd; both pane directory fields can be stale. + const withoutCwd = { process_info: { foreground_processes: processes.map(({ pid, argv }) => ({ pid, argv })) } }; + const stale = snapshot.panes.filter((entry) => entry.pane_id === paneId).map((entry) => + ({ ...entry, cwd: join(root, "stale-pane"), foreground_cwd: join(root, "stale-foreground") })); + const resolved = omoTranscriptsOfCwd(root, stale, new Map([[paneId, withoutCwd]]), root).get(paneId); + expect(resolved?.path).toBe(realpathSync(written)); + expect(resolved?.pending).toBeNull(); + for (const agent of ["codex", "devin", "gjc"]) { + const relabelled = { ...snapshot, panes: snapshot.panes.map((entry) => entry.pane_id === paneId ? { ...entry, agent } : entry) }; + expect((await omoPanes(relabelled.panes)).get(paneId)?.path).toBe(realpathSync(written)); + expect((await paneTranscript(paneId, undefined, relabelled)).source).toBe("omo-transcript"); + } + +}); + +it.skipIf(process.platform !== "linux")("recognizes a custom launcher in the default store and refuses a senpi-only one", async () => { + const plain = join(root, "plain-runner"); + writeFileSync(plain, "setInterval(() => {}, 1000);\n"); + const paneId = await launch(`env -u OMO_CODING_AGENT_DIR -u SENPI_CODING_AGENT_DIR -u PI_CODING_AGENT_DIR ${process.execPath} ${plain}`, plain); + await hold(paneId, "default-held", dir, plain); + const path = session("default-held", "2020-01-01T00:00:00Z"); + expect(await omoTranscriptForPane(paneId, root, (await sessionSnapshot()).panes, root)).toBe(realpathSync(path)); + // the same launcher under a store moved only by SENPI's variable is not omo + const senpi = join(root, "senpi-runner"); + writeFileSync(senpi, "setInterval(() => {}, 1000);\n"); + const otherDir = join(root, "senpi-state", "sessions", `-${root.replaceAll("/", "-")}--`); + const paneId2 = await launch(`env -u OMO_CODING_AGENT_DIR -u PI_CODING_AGENT_DIR SENPI_CODING_AGENT_DIR=${join(root, "senpi-state")} ${process.execPath} ${senpi}`, senpi); + await hold(paneId2, "senpi-held", otherDir, senpi); + expect(await omoSessionForPane(paneId2, root, (await sessionSnapshot()).panes, root)).toEqual({ path: null, pending: null }); +}); diff --git a/server/omo.test.ts b/server/omo.test.ts index 20c2fdfeb..d205857dc 100644 --- a/server/omo.test.ts +++ b/server/omo.test.ts @@ -3,9 +3,10 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync import { tmpdir } from "node:os"; import { join } from "node:path"; import type { HerdrPane } from "../shared/protocol.ts"; -import { heldRuntime, heldSessionIds, isOmoProcess, omoAgentDir, omoSessionFolder, omoCandidates, omoTranscriptsOfCwd, selectOmoTranscript, type OmoRuntime } from "./omo.ts"; +import { heldRuntime, heldSessionIds, isOmoProcess, omoAgentDir, omoProcessIdentity, omoSessionFolder, omoCandidates, omoTranscriptsOfCwd, selectOmoTranscript, withOmoDescendants, type OmoRuntime, type ProcessInfo } from "./omo.ts"; import { processStartedAt } from "./process-start.ts"; import { parseOmpTranscript } from "./transcript-records.ts"; +import type { ProcessRow } from "./windows-processes.ts"; const runtime = (paneId: string, startedAt: number | null = 10_000, paths: string[] = [], ids: string[] = []): OmoRuntime => ({ paneId, startedAt, paths, ids }); const files = [ @@ -96,7 +97,7 @@ it("reads session identity from bounded headers and rejects foreign cwd and esca session(join(home, "outside.jsonl"), "/project"); symlinkSync(join(home, "outside.jsonl"), join(dir, "escaped.jsonl")); writeFileSync(join(dir, "broken.jsonl"), "{"); - expect(omoCandidates("/project", home)).toEqual([{ path: join(dir, "valid.jsonl"), id: "session-1", createdAt: Date.parse("2026-09-27T00:00:00Z") }]); + expect(omoCandidates("/project", home)).toEqual([{ path: join(dir, "valid.jsonl"), id: "session-1", createdAt: Date.parse("2026-09-27T00:00:00Z"), store: join(home, ".omo", "agent", "sessions") }]); expect(omoCandidates("/missing", home)).toEqual([]); } finally { rmSync(home, { recursive: true, force: true }); } }); @@ -154,6 +155,14 @@ it("finds omo's agent directory where the process's environment moved it", () => expect(omoAgentDir(["OMO_CODING_AGENT_DIR=", "SENPI_CODING_AGENT_DIR=/senpi"], "/home/u", "/project")).toBe("/home/u/.omo/agent"); }); +it("falls back to the pane cwd when the OS process cwd is unavailable", () => { + const env = () => ["OMO_CODING_AGENT_DIR=relative-agent"]; + expect(omoProcessIdentity({ pid: 2 ** 30, argv: ["omo"] }, "/pane", "/home/u", env)?.agentDir) + .toBe("/pane/relative-agent"); + expect(omoProcessIdentity({ pid: 2 ** 30, argv: ["omo"], cwd: "/reported" }, "/pane", "/home/u", env)?.agentDir) + .toBe("/reported/relative-agent"); +}); + it("binds an omo pane whose launcher moved its agent directory out of ~/.omo", () => { // A launcher profile (OMO_CODING_AGENT_DIR=/.omo/agent) wrote the session and its // holder record there, and ~/.omo/agent/sessions had no folder for the cwd: the chat lens @@ -182,7 +191,7 @@ it("binds an omo pane whose launcher moved its agent directory out of ~/.omo", ( const pane = { pane_id: "w1:p1", cwd } as HerdrPane; const argv = ["/usr/bin/bun", "/opt/omo/node_modules/@code-yeongyu/senpi/dist/bundle/cli.js", "--extension", "/opt/omo/node_modules/omo-ai/plugin"]; - const infos = new Map([[pane.pane_id, { process_info: { foreground_processes: [{ pid, argv }] } }]]); + const infos = new Map([[pane.pane_id, { process_info: { foreground_processes: [{ pid, argv, cwd }] } }]]); const moved = () => ["HOME=" + home, `OMO_CODING_AGENT_DIR=${agentDir}`]; expect(omoTranscriptsOfCwd(cwd, [pane], infos, home, () => ["HOME=" + home]).get(pane.pane_id)?.path).toBeNull(); @@ -200,3 +209,254 @@ it("names a cwd's session folder as omo's engine does, a Windows cwd included", expect(omoSessionFolder("C:\\Users\\me\\dev\\app")).toBe("--C--Users-me-dev-app--"); expect(omoSessionFolder("C:/Users/me/app")).toBe("--C--Users-me-app--"); }); + +// herdr on Windows names the pane's launcher, not what the launcher starts (windows-processes.ts) +const launcherInfo: ProcessInfo = { process_info: { shell_pid: 10, foreground_processes: [{ pid: 11, argv: ["C:\\tools\\pi.exe"] }] } }; +const launcherRows: ProcessRow[] = [ + { pid: 10, parent: 1, path: "C:\\pwsh.exe", commandLine: "pwsh.exe" }, + { pid: 11, parent: 10, path: "C:\\tools\\pi.exe", commandLine: "pi.exe" }, + { pid: 12, parent: 11, path: "C:\\bun.exe", commandLine: 'bun.exe "C:\\node_modules\\omo-ai\\bin\\cli.js" --session-id session-123' }, + // below another pane's shell + { pid: 13, parent: 2, path: "C:\\bun.exe", commandLine: 'bun.exe "C:\\node_modules\\omo-ai\\bin\\cli.js" --session-id other-session' }, +]; + +it("finds the OmO a Windows launcher started below the pane's shell, with its session arguments", () => { + const processes = withOmoDescendants(launcherInfo, launcherRows)?.process_info?.foreground_processes ?? []; + expect(processes.map((entry) => entry.pid)).toEqual([11, 12]); + expect(isOmoProcess(processes[1]!.argv!)).toBeTrue(); + expect(processes[1]!.argv).toContain("session-123"); + expect(launcherInfo?.process_info?.foreground_processes).toHaveLength(1); +}); + +it("does not turn an ordinary pi into OmO or borrow another pane's OmO", () => { + expect(withOmoDescendants(launcherInfo, launcherRows.filter((entry) => entry.pid !== 12))).toEqual(launcherInfo); + expect(withOmoDescendants(launcherInfo, [])).toEqual(launcherInfo); + expect(withOmoDescendants({ process_info: { foreground_processes: [] } }, launcherRows)).toEqual({ process_info: { foreground_processes: [] } }); + expect(withOmoDescendants(null, launcherRows)).toBeNull(); +}); + +it("does not list an OmO process twice when herdr already names it", () => { + const once = withOmoDescendants(launcherInfo, launcherRows); + expect(withOmoDescendants(once, launcherRows)).toEqual(once); +}); + +const holdRecord = (dir: string, id: string, pid: number, record: object): void => { + const holders = join(dir, "session-holders", encodeURIComponent(id)); + mkdirSync(holders, { recursive: true }); + writeFileSync(join(holders, `${pid}.json`), JSON.stringify(record)); +}; +const writeSession = (dir: string, id: string, cwd: string): string => { + mkdirSync(dir, { recursive: true }); + const path = join(dir, `2026-10-02T16-47-10-905Z_${id}.jsonl`); + writeFileSync(path, JSON.stringify({ type: "session", id, cwd, timestamp: "2026-10-02T16:47:10.905Z" }) + "\n"); + return path; +}; + +it("identifies an argv-silent omo runtime only through verified native holders", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-omo-custom-"))); + try { + const home = join(root, "home"); + const cwd = join(root, "project"); + const agentDir = join(root, "state", "custom", "agent"); + const dir = join(agentDir, "sessions", omoSessionFolder(cwd)); + mkdirSync(dir, { recursive: true }); + const pid = process.pid; + const neutral = ["/opt/team-ai/bin/run", "--profile", "work"]; + const env = [`OMO_CODING_AGENT_DIR=${agentDir}`]; + holdRecord(dir, "sess-1", pid, { pid, processStartedAtMs: processStartedAt(pid), cwd }); + // a launcher whose name and argv say nothing about omo still binds, by its store + expect(omoProcessIdentity({ pid, argv: neutral, cwd }, cwd, home, () => env)) + .toEqual({ agentDir, heldIds: ["sess-1"], custom: true }); + // the process's own cwd counts when the caller does not supply the pane's + expect(omoProcessIdentity({ pid, argv: neutral, cwd }, "", home, () => env)?.custom).toBeTrue(); + // the same holder in the default store needs no environment entry at all + const defaultDir = join(home, ".omo", "agent", "sessions", omoSessionFolder(cwd)); + holdRecord(defaultDir, "home-sess", pid, { pid, processStartedAtMs: processStartedAt(pid), cwd }); + expect(omoProcessIdentity({ pid, argv: neutral, cwd }, cwd, home, () => ["PATH=/bin"])) + .toEqual({ agentDir: join(home, ".omo", "agent"), heldIds: ["home-sess"], custom: true }); + // a store moved only by senpi's or pi's variable is not an omo identity + expect(omoProcessIdentity({ pid, argv: neutral, cwd }, cwd, home, () => [`SENPI_CODING_AGENT_DIR=${agentDir}`])).toBeNull(); + expect(omoProcessIdentity({ pid, argv: neutral, cwd }, cwd, home, () => [`PI_CODING_AGENT_DIR=${agentDir}`])).toBeNull(); + // recognized argv stays the legacy identity, no holder required + const legacy = omoProcessIdentity({ pid, argv: ["node", `${OMO_AI}/bin/omo.js`], cwd }, cwd, home, () => null); + expect(legacy?.custom).toBeFalse(); + expect(legacy?.agentDir).toBe(join(home, ".omo", "agent")); + // a bun -g hoisted engine is the same legacy identity + const modules = "/home/u/.bun/install/global/node_modules"; + expect(omoProcessIdentity({ pid, argv: ["bun", `${modules}/@code-yeongyu/senpi/dist/bundle/cli.js`, "--extension", `${modules}/omo-ai/plugin`], cwd }, cwd, home, () => null)?.custom).toBeFalse(); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("never takes an env marker, an argv mention, a wrong cwd or a stale start for a custom omo", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-omo-refuse-"))); + try { + const home = join(root, "home"); + const cwd = join(root, "project"); + const agentDir = join(root, "state", "agent"); + const dir = join(agentDir, "sessions", omoSessionFolder(cwd)); + mkdirSync(dir, { recursive: true }); + const pid = process.pid; + const neutral = ["/opt/team-ai/bin/run"]; + const env = [`OMO_CODING_AGENT_DIR=${agentDir}`]; + // the environment entry alone proves nothing without a holder + expect(omoProcessIdentity({ pid, argv: neutral, cwd }, cwd, home, () => env)).toBeNull(); + // a holder another pid wrote, a foreign cwd and a start that is not this + // process's own each fail closed + holdRecord(dir, "other-pid", pid, { pid: pid + 1, processStartedAtMs: processStartedAt(pid), cwd }); + expect(omoProcessIdentity({ pid, argv: neutral, cwd }, cwd, home, () => env)).toBeNull(); + rmSync(join(dir, "session-holders", "other-pid"), { recursive: true }); + holdRecord(dir, "foreign", pid, { pid, processStartedAtMs: processStartedAt(pid), cwd: "/elsewhere" }); + expect(omoProcessIdentity({ pid, argv: neutral, cwd }, cwd, home, () => env)).toBeNull(); + rmSync(join(dir, "session-holders", "foreign"), { recursive: true }); + holdRecord(dir, "stale", pid, { pid, processStartedAtMs: (processStartedAt(pid) ?? 0) - 60_000, cwd }); + expect(omoProcessIdentity({ pid, argv: neutral, cwd }, cwd, home, () => env)).toBeNull(); + rmSync(join(dir, "session-holders", "stale"), { recursive: true }); + // a pid nothing runs has no start to match a holder to + expect(omoProcessIdentity({ pid: 2 ** 30, argv: neutral }, cwd, home, () => env)).toBeNull(); + // argv words that mention omo are still nothing without the holders + expect(omoProcessIdentity({ pid, argv: ["grep", "-q", `${OMO_AI}/x`], cwd }, cwd, home, () => env)).toBeNull(); + // and without a resolvable cwd a custom identity cannot be verified at all + expect(omoProcessIdentity({ pid: 2 ** 30, argv: neutral }, "", home, () => env)).toBeNull(); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("binds same-id sessions in different stores to the pane that owns each store", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-omo-stores-"))); + try { + const home = join(root, "home"); + const cwd = join(root, "project"); + mkdirSync(join(home, ".omo", "agent", "sessions"), { recursive: true }); + mkdirSync(cwd, { recursive: true }); + const storeA = join(root, "a", "agent"); + const storeB = join(root, "b", "agent"); + const dirA = join(storeA, "sessions", omoSessionFolder(cwd)); + const dirB = join(storeB, "sessions", omoSessionFolder(cwd)); + const pidA = process.pid; + const pidB = process.ppid; + holdRecord(dirA, "dup", pidA, { pid: pidA, processStartedAtMs: processStartedAt(pidA), cwd }); + holdRecord(dirB, "dup", pidB, { pid: pidB, processStartedAtMs: processStartedAt(pidB), cwd }); + const fileA = writeSession(dirA, "dup", cwd); + const panes = [{ pane_id: "w:p1", cwd } as HerdrPane, { pane_id: "w:p2", cwd } as HerdrPane]; + const launcher = ["/opt/team-ai/bin/run"]; + const infos = new Map([ + ["w:p1", { process_info: { foreground_processes: [{ pid: pidA, argv: launcher, cwd }] } }], + ["w:p2", { process_info: { foreground_processes: [{ pid: pidB, argv: launcher, cwd }] } }], + ]); + const envOf = (pid: number) => pid === pidA ? [`OMO_CODING_AGENT_DIR=${storeA}`] : pid === pidB ? [`OMO_CODING_AGENT_DIR=${storeB}`] : null; + const first = omoTranscriptsOfCwd(cwd, panes, infos, home, envOf); + expect(first.get("w:p1")?.path).toBe(realpathSync(fileA)); + // B's held id is pending in B's store; A's written file of the same id is not B's + expect(first.get("w:p2")?.path).toBeNull(); + expect(first.get("w:p2")?.pending).toBe("dup"); + // once B writes its own file each pane keeps its own transcript + const fileB = writeSession(dirB, "dup", cwd); + const second = omoTranscriptsOfCwd(cwd, panes, infos, home, envOf); + expect(second.get("w:p1")?.path).toBe(realpathSync(fileA)); + expect(second.get("w:p2")?.path).toBe(realpathSync(fileB)); + // an escaped symlink inside a store is still no candidate + const outside = join(root, "outside.jsonl"); + writeFileSync(outside, JSON.stringify({ type: "session", id: "dup", cwd, timestamp: "2026-10-02T16:47:10.905Z" }) + "\n"); + symlinkSync(outside, join(dirB, "escaped.jsonl")); + const third = omoTranscriptsOfCwd(cwd, panes, infos, home, envOf); + expect(third.get("w:p2")?.path).toBe(realpathSync(fileB)); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("fails closed when two panes hold the same session file", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-omo-conflict-"))); + try { + const home = join(root, "home"); + const cwd = join(root, "project"); + const agentDir = join(root, "agent"); + const dir = join(agentDir, "sessions", omoSessionFolder(cwd)); + const pidA = process.pid; + const pidB = process.ppid; + holdRecord(dir, "contested", pidA, { pid: pidA, processStartedAtMs: processStartedAt(pidA), cwd }); + holdRecord(dir, "contested", pidB, { pid: pidB, processStartedAtMs: processStartedAt(pidB), cwd }); + writeSession(dir, "contested", cwd); + const panes = [{ pane_id: "w:p1", cwd } as HerdrPane, { pane_id: "w:p2", cwd } as HerdrPane]; + const infos = new Map([ + ["w:p1", { process_info: { foreground_processes: [{ pid: pidA, argv: ["/opt/team-ai/run"], cwd }] } }], + ["w:p2", { process_info: { foreground_processes: [{ pid: pidB, argv: ["/opt/team-ai/run"], cwd }] } }], + ]); + const out = omoTranscriptsOfCwd(cwd, panes, infos, home, () => [`OMO_CODING_AGENT_DIR=${agentDir}`]); + expect(out.get("w:p1")?.path).toBeNull(); + expect(out.get("w:p2")?.path).toBeNull(); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("returns a custom pane's pending session and nothing once its holder is gone", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-omo-pending-"))); + try { + const home = join(root, "home"); + const cwd = join(root, "project"); + const agentDir = join(root, "agent"); + const dir = join(agentDir, "sessions", omoSessionFolder(cwd)); + const pid = process.pid; + const pane = { pane_id: "w:p1", cwd } as HerdrPane; + const infos = new Map([[pane.pane_id, { process_info: { foreground_processes: [{ pid, argv: ["/opt/team-ai/run"], cwd }] } }]]); + const env = () => [`OMO_CODING_AGENT_DIR=${agentDir}`]; + const run = () => omoTranscriptsOfCwd(cwd, [pane], infos, home, env); + // no holder: the launcher is not omo, and no file inference rescues it + writeSession(dir, "older-session", cwd); + expect(run().has(pane.pane_id)).toBeFalse(); + holdRecord(dir, "first-session", pid, { pid, processStartedAtMs: processStartedAt(pid), cwd }); + const pending = run().get(pane.pane_id); + expect(pending?.path).toBeNull(); + expect(pending?.pending).toBe("first-session"); + const written = writeSession(dir, "first-session", cwd); + expect(run().get(pane.pane_id)?.path).toBe(realpathSync(written)); + // /new: the next held id is pending; the file it left is not resurrected + rmSync(join(dir, "session-holders", "first-session"), { recursive: true }); + holdRecord(dir, "after-new", pid, { pid, processStartedAtMs: processStartedAt(pid), cwd }); + const afterNew = run().get(pane.pane_id); + expect(afterNew?.path).toBeNull(); + expect(afterNew?.pending).toBe("after-new"); + // and with no holder left the launcher is no runtime at all + rmSync(join(dir, "session-holders", "after-new"), { recursive: true }); + expect(run().has(pane.pane_id)).toBeFalse(); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("keeps a scoped runtime's inference and launch-id guard inside its own store", () => { + const a = { ...runtime("a"), stores: ["/a/sessions"] }; + // a fresh file in another pane's store is not evidence for this pane's runtime, + // and a file outside every named store counts no more than a missing one + const foreignFresh = { path: "/b/fresh.jsonl", id: "fresh", createdAt: 10_000, store: "/b/sessions" }; + const ownFresh = { path: "/a/fresh.jsonl", id: "fresh", createdAt: 10_000, store: "/a/sessions" }; + expect(selectOmoTranscript("a", [foreignFresh], [a], 20_000)).toBeNull(); + expect(selectOmoTranscript("a", [ownFresh], [a], 20_000)).toBe("/a/fresh.jsonl"); + expect(selectOmoTranscript("a", [{ path: "/unstored.jsonl", id: "fresh", createdAt: 10_000 }], [a], 20_000)).toBeNull(); + // the /new ambiguity guard of a launch-id hint looks only inside the owned store: + // a newer foreign file leaves the hint alone, a newer own one unsettles it + const hinted = { ...a, ids: ["old-session"] }; + const old = { path: "/a/old.jsonl", id: "old-session", createdAt: 1_000, store: "/a/sessions" }; + const foreignNewer = { path: "/b/new.jsonl", id: "new", createdAt: 15_000, store: "/b/sessions" }; + const ownNewer = { path: "/a/new.jsonl", id: "new", createdAt: 15_000, store: "/a/sessions" }; + expect(selectOmoTranscript("a", [old, foreignNewer], [hinted], 20_000)).toBe("/a/old.jsonl"); + expect(selectOmoTranscript("a", [old, ownNewer], [hinted], 20_000)).toBeNull(); +}); + +it("fails closed on distinct holder claims while duplicates of one claim still bind", () => { + const a = { ...runtime("a"), stores: ["/a/sessions"] }; + const fileA = { path: "/a/one.jsonl", id: "one", createdAt: 10_000, store: "/a/sessions" }; + // two different sessions claimed: ambiguous even though only one has a written file + const contested = heldRuntime(a, [{ id: "one", store: "/a/sessions" }, { id: "two", store: "/a/sessions" }], [fileA]); + expect(contested.ambiguous).toBeTrue(); + expect(selectOmoTranscript("a", [fileA], [contested], 20_000)).toBeNull(); + // the same id held under another store is a different claim, not a duplicate + expect(heldRuntime(a, [{ id: "one", store: "/a/sessions" }, { id: "one", store: "/b/sessions" }], [fileA]).ambiguous).toBeTrue(); + expect(heldRuntime(a, ["one", "two"], [fileA]).ambiguous).toBeTrue(); + // the same claim seen twice (two of the pane's processes hold one session) stays valid + for (const held of [[{ id: "one", store: "/a/sessions" }, { id: "one", store: "/a/sessions" }], ["one", "one"]] as const) { + const claimed = heldRuntime(a, held, [fileA]); + expect(claimed.ambiguous).not.toBeTrue(); + expect(selectOmoTranscript("a", [fileA], [claimed], 20_000)).toBe("/a/one.jsonl"); + } + // a held id whose store wrote nothing is pending; another store's file of the same id is not its + const pending = heldRuntime({ ...a, stores: ["/b/sessions"] }, [{ id: "one", store: "/b/sessions" }], [fileA]); + expect(pending.paths).toEqual([]); + expect(pending.ids).toEqual([]); + expect(pending.claims).toEqual([{ id: "one", store: "/b/sessions" }]); + expect(selectOmoTranscript("a", [fileA], [pending], 20_000)).toBeNull(); +}); diff --git a/server/omo.ts b/server/omo.ts index 975f8b82a..d07125168 100644 --- a/server/omo.ts +++ b/server/omo.ts @@ -2,7 +2,9 @@ import { constants, fstatSync, closeSync, openSync, readdirSync, readFileSync, r import { isAbsolute, join, relative, resolve, sep } from "node:path"; import type { HerdrPane } from "../shared/protocol.ts"; import { herdrRpc } from "./herdr/client.ts"; +import { recentProcessTable } from "./gjc-runtime.ts"; import { processStartedAt } from "./process-start.ts"; +import { descendantProcesses, type ProcessRow } from "./windows-processes.ts"; const OMO_PROCESS = /(^|\/)omo(\.js)?$|\/omo-ai\//; /** node and bun run a script: the program is then the script, the first word that is not a flag */ @@ -49,20 +51,43 @@ function isPathList(word: string): boolean { return word.replace(/^[A-Za-z]:\//, "").includes(":"); } -export interface OmoCandidate { path: string; id: string; createdAt: number | null } +export interface OmoCandidate { path: string; id: string; createdAt: number | null; store?: string } export interface OmoRuntime { paneId: string; startedAt: number | null; /** Exact native session evidence, before considering a cwd/time inference. */ paths: string[]; ids: string[]; + /** + * The canonical sessions roots this pane's own processes write to. A path or id + * evidence matches only inside them: the same session id in another pane's store is + * another session. Unset means unscoped (older callers and pure-selection tests). + */ + stores?: string[]; + /** + * The sessions the pane's processes hold now, each bound to the canonical store that + * holds it (undefined: an unscoped caller claim matching by id alone; null: the store + * could not be resolved, so the claim matches no file). A claim whose file is missing + * or unreadable still binds, so it never resolves to another store's same-id session. + */ + claims?: { id: string; store?: string | null }[]; + /** Recognized only by verified native holders: hints and cwd/time inference never apply. */ + custom?: boolean; + ambiguous?: boolean; } /** A session is never selected by mtime: tool activity is not evidence of pane ownership. */ export function selectOmoTranscript(paneId: string, candidates: OmoCandidate[], runtimes: OmoRuntime[], now = Date.now()): string | null { - const direct = (runtime: OmoRuntime): OmoCandidate[] => candidates.filter((file) => runtime.paths.includes(file.path) || runtime.ids.includes(file.id)); + // A structured held claim matches only inside the store that holds it (an unresolved + // store matches nothing); a legacy unscoped claim still matches by id alone. + const held = (runtime: OmoRuntime, file: OmoCandidate): boolean => (runtime.claims ?? []).some((claim) => + claim.id === file.id && (claim.store === undefined || claim.store === file.store)); + const direct = (runtime: OmoRuntime): OmoCandidate[] => candidates.filter((file) => + (runtime.stores === undefined || (file.store !== undefined && runtime.stores.includes(file.store))) + && (runtime.paths.includes(file.path) || runtime.ids.includes(file.id) || held(runtime, file))); const target = runtimes.find((runtime) => runtime.paneId === paneId); - if (!target) return null; + if (!target || target.ambiguous) return null; + const owned = (file: OmoCandidate) => target.stores === undefined || (file.store !== undefined && target.stores.includes(file.store)); const exact = direct(target); const claimed = new Set(runtimes.filter((runtime) => runtime !== target).flatMap((runtime) => direct(runtime).map((file) => file.path))); if (target.paths.length > 0 || target.ids.length > 0) { @@ -70,12 +95,18 @@ export function selectOmoTranscript(paneId: string, candidates: OmoCandidate[], // A launch ID can outlive /new. Without a current descriptor, a later unclaimed // session makes that hint ambiguous rather than pinning the old conversation. if (!target.paths.includes(exact[0]!.path) && target.startedAt !== null && candidates.some((file) => - file.path !== exact[0]!.path && !claimed.has(file.path) && file.createdAt !== null && file.createdAt > target.startedAt! + 1000 && file.createdAt <= now + 1000)) return null; + owned(file) && file.path !== exact[0]!.path && !claimed.has(file.path) && file.createdAt !== null && file.createdAt > target.startedAt! + 1000 && file.createdAt <= now + 1000)) return null; return exact[0]!.path; } + // A held claim whose file is missing or unreadable still binds its id to its own + // store: it names that session alone — never another store's same-id file, and no + // cwd/time inference. + if (target.claims !== undefined && target.claims.length > 0) return null; + // A custom runtime is only ever its holders: no cwd/time inference, no launch hints. + if (target.custom) return null; // A second process (or an unreadable peer) sharing cwd blocks inference entirely. if (runtimes.length !== 1 || target.startedAt === null) return null; - const fresh = candidates.filter((file) => file.createdAt !== null && file.createdAt >= target.startedAt! - 1000 && file.createdAt <= now + 1000); + const fresh = candidates.filter((file) => owned(file) && file.createdAt !== null && file.createdAt >= target.startedAt! - 1000 && file.createdAt <= now + 1000); return fresh.length === 1 ? fresh[0]!.path : null; } @@ -92,7 +123,8 @@ function candidate(path: string, roots: readonly string[], cwd: string): OmoCand let fd: number | undefined; try { const canonical = realpathSync(path); - if (!canonical.endsWith(".jsonl") || !roots.some((root) => inStore(canonical, root))) return null; + const root = roots.find((root) => inStore(canonical, root)); + if (!canonical.endsWith(".jsonl") || root === undefined) return null; fd = openSync(canonical, constants.O_RDONLY | constants.O_NONBLOCK); if (!fstatSync(fd).isFile()) return null; const bytes = Buffer.alloc(4096); @@ -100,7 +132,7 @@ function candidate(path: string, roots: readonly string[], cwd: string): OmoCand const header = JSON.parse(bytes.subarray(0, length).toString("utf8").split("\n")[0]!); if (header?.type !== "session" || header.cwd !== cwd || typeof header.id !== "string") return null; const timestamp = typeof header.timestamp === "string" ? Date.parse(header.timestamp) : NaN; - return { path: canonical, id: header.id, createdAt: Number.isFinite(timestamp) ? timestamp : null }; + return { path: canonical, id: header.id, createdAt: Number.isFinite(timestamp) ? timestamp : null, store: root }; } catch { return null; } finally { if (fd !== undefined) closeSync(fd); } } @@ -133,6 +165,51 @@ export function processEnviron(pid: number): string[] | null { try { return readFileSync(`/proc/${pid}/environ`, "utf8").split("\0"); } catch { return null; } } +/** + * Where a process actually runs: the cwd herdr reports for the process itself, then + * the OS's own answer (Linux), then the caller's (the pane's). "" when no source can + * say — a custom identity cannot be verified without it. A pane cwd goes stale the + * moment a process moves, so it must never override either process-specific source. + */ +function processCwd(process: { pid: number; cwd?: string }, cwd: string): string { + if (typeof process.cwd === "string" && process.cwd) return process.cwd; + try { return readlinkSync(`/proc/${process.pid}/cwd`); } catch { return cwd; } +} + +/** + * Is this process omo, and which store does its session live in? Two proofs: + * - legacy: the program it runs is omo's (isOmoProcess). Its environment still moves + * the store the same way, and its holders still name the session it owns now; + * - custom: the argv says nothing (a launcher's own name is not evidence either way), + * so only verified native session ownership counts: an OMO-specific store — an + * explicit `OMO_CODING_AGENT_DIR` entry (an empty one means the default) or the + * resolved store being the default `~/.omo/agent`; a store moved only by SENPI's or + * pi's variable is not an omo identity — plus a live OS start time and at least one + * holder record for this exact pid that names a matching start and the process's + * own cwd. An env marker alone, an argv mention, a stale or reused pid, a wrong cwd + * or an unreadable start never identify a custom omo, and nothing here depends on + * the model it runs. + */ +export function omoProcessIdentity( + process: { pid: number; argv?: string[]; cwd?: string }, + cwd: string, + home: string, + environOf: (pid: number) => readonly string[] | null = processEnviron, +): { agentDir: string; heldIds: string[]; custom: boolean } | null { + const dir = processCwd(process, cwd); + const environ = environOf(process.pid); + const agentDir = omoAgentDir(environ, home, dir); + const startedAt = processStartedAt(process.pid); + if (isOmoProcess(process.argv ?? [])) { + return { agentDir, heldIds: heldSessionIds(sessionDir(dir, agentDir), process.pid, startedAt), custom: false }; + } + if (!dir || startedAt === null) return null; + const omoStore = environ?.some((word) => word.startsWith("OMO_CODING_AGENT_DIR=")) === true || agentDir === defaultOmoAgentDir(home); + if (!omoStore) return null; + const heldIds = heldSessionIds(sessionDir(dir, agentDir), process.pid, startedAt, dir); + return heldIds.length === 0 ? null : { agentDir, heldIds, custom: true }; +} + /** * omo's engine (senpi, core/session-manager.js) names a cwd's session folder exactly so: the * leading slash dropped, then every slash, backslash and colon a dash. A Windows cwd @@ -153,7 +230,7 @@ const HOLDER_START_TOLERANCE_MS = 3000; * counts only for the live pid it names and, where our start time is readable, only * if it started when that pid did: a reused pid starts later. */ -export function heldSessionIds(dir: string, pid: number, startedAt: number | null): string[] { +export function heldSessionIds(dir: string, pid: number, startedAt: number | null, cwd?: string): string[] { const holders = join(dir, "session-holders"); let names: string[] = []; try { names = readdirSync(holders); } catch { return []; } @@ -162,6 +239,7 @@ export function heldSessionIds(dir: string, pid: number, startedAt: number | nul try { const record = JSON.parse(readFileSync(join(holders, name, `${pid}.json`), "utf8")); if (record?.pid !== pid) continue; + if (cwd !== undefined && record.cwd !== cwd) continue; if (startedAt !== null && !(typeof record.processStartedAtMs === "number" && Math.abs(record.processStartedAtMs - startedAt) <= HOLDER_START_TOLERANCE_MS)) continue; ids.push(decodeURIComponent(name)); } catch { /* this pid holds nothing here, or the record is unreadable */ } @@ -176,10 +254,17 @@ export function heldSessionIds(dir: string, pid: number, startedAt: number | nul * conversation not begun (/new, nothing typed since): it names that id alone, never a file * from before. */ -export function heldRuntime(runtime: OmoRuntime, held: readonly string[], files: readonly OmoCandidate[]): OmoRuntime { +export function heldRuntime(runtime: OmoRuntime, held: readonly (string | { id: string; store: string | null })[], files: readonly OmoCandidate[]): OmoRuntime { if (held.length === 0) return runtime; - const current = files.filter((file) => held.includes(file.id)).map((file) => file.path); - return { ...runtime, paths: current, ids: current.length > 0 ? [] : [...held] }; + const claims: { id: string; store?: string | null }[] = held.map((heldId) => typeof heldId === "string" ? { id: heldId, store: undefined } : heldId); + const ambiguous = new Set(claims.map((claim) => JSON.stringify([claim.store, claim.id]))).size > 1 || runtime.ambiguous === true; + // A held id counts only in the store that holds it: another store's file of the same + // id is a different session that happens to share the name. The claims stay on the + // runtime so a missing or unreadable header still binds; only an unscoped claim — a + // caller that knows no store — joins the store-wide id evidence. + const current = files.filter((file) => claims.some((claim) => claim.id === file.id && (claim.store === undefined || file.store === claim.store))).map((file) => file.path); + const ids = current.length > 0 ? [] : [...new Set(claims.filter((claim) => claim.store === undefined).map((claim) => claim.id))]; + return { ...runtime, paths: current, ids, claims, ambiguous }; } /** When the process that holds a session here started, by its own record: where the system does not tell (macOS). */ @@ -231,39 +316,110 @@ function resumedIds(argv: string[]): string[] { return result; } -export type ProcessInfo = { process_info?: { foreground_processes?: { pid: number; argv?: string[] }[] } } | null; -const processInfo = (paneId: string): Promise => herdrRpc>("pane.process_info", { pane_id: paneId }).catch(() => null); +export type ProcessInfo = { process_info?: { shell_pid?: number; foreground_processes?: { pid: number; argv?: string[]; cwd?: string }[] } } | null; + +/** + * herdr on Windows names only the pane's launcher in foreground_processes, not what the launcher + * starts (windows-processes.ts). The OmO below the pane's shell joins the list, so the pane is + * seen as OmO and its session arguments are read. Only processes below this pane's own shell + * count, and one herdr already named is not added twice. + */ +export function withOmoDescendants(info: ProcessInfo, rows: readonly ProcessRow[]): ProcessInfo { + const shell = info?.process_info?.shell_pid; + if (shell === undefined) return info; + const foreground = info?.process_info?.foreground_processes ?? []; + const omo = descendantProcesses(rows, shell).filter((entry) => + isOmoProcess(entry.argv) && !foreground.some((existing) => existing.pid === entry.pid)); + return omo.length === 0 ? info : { ...info, process_info: { ...info?.process_info, foreground_processes: [...foreground, ...omo] } }; +} + +const processInfo = async (paneId: string): Promise => { + const [info, rows] = await Promise.all([ + herdrRpc>("pane.process_info", { pane_id: paneId }).catch(() => null), + process.platform === "win32" ? recentProcessTable() : Promise.resolve([]), + ]); + return withOmoDescendants(info, rows); +}; + +type OmoForeground = { pid: number; argv?: string[]; cwd?: string }; + +/** The pane's own directory evidence: herdr's foreground cwd, then the cwd the pane started in. */ +const paneFallbackCwd = (pane: HerdrPane): string => + (typeof pane.foreground_cwd === "string" && pane.foreground_cwd) || pane.cwd || ""; + +/** + * One pane's recognized omo processes — each with the cwd it actually runs in — and the + * one folder its runtime binds. The processes' own cwds decide, a pane directory being + * able to go stale while a process runs elsewhere: the unique cwd among processes that + * hold a session wins, else the unique cwd among the recognized ones, else the pane's + * own. Two different holder-owning cwds is ambiguous and must not select a transcript. + */ +interface PaneResolution { + pane: HerdrPane; + /** herdr answered the pane's process list (an empty one is an answer) */ + informed: boolean; + omos: { process: OmoForeground; identity: NonNullable>; cwd: string }[]; + cwd: string; + ambiguous: boolean; +} + +function resolvePaneRuntime(pane: HerdrPane, info: ProcessInfo, home: string, environOf: (pid: number) => readonly string[] | null): PaneResolution { + const fallback = paneFallbackCwd(pane); + const omos = (info?.process_info?.foreground_processes ?? []).flatMap((process) => { + const identity = omoProcessIdentity(process, fallback, home, environOf); + return identity === null ? [] : [{ process, identity, cwd: processCwd(process, fallback) }]; + }); + const holding = [...new Set(omos.filter((omo) => omo.identity.heldIds.length > 0).map((omo) => omo.cwd))]; + const running = [...new Set(omos.map((omo) => omo.cwd))]; + return { + pane, + informed: info?.process_info?.foreground_processes != null, + omos, + cwd: holding.length === 1 ? holding[0]! : running.length === 1 ? running[0]! : fallback, + ambiguous: holding.length > 1, + }; +} + +/** A pane answers for its resolved folder; an ambiguous one still claims in each folder a process of its runs in. */ +const inCwdGroup = (resolved: PaneResolution, cwd: string): boolean => + resolved.cwd === cwd || (resolved.ambiguous && resolved.omos.some((omo) => omo.cwd === cwd)); /** * The session each OmO pane of one folder holds, from the processes herdr named for its panes. - * Same-cwd peers are inspected even when herdr calls omo's SDK child `claude`. + * Same-cwd peers are inspected even when herdr calls omo's SDK child `claude` and when a + * custom launcher's argv says nothing (omoProcessIdentity). Every piece of evidence — + * held ids, open descriptors, herdr's report, a launch hint — binds only inside the + * canonical stores that pane's own processes resolved; another store's file of the same + * id is a different session, and a pending holder never borrows it. */ -export function omoTranscriptsOfCwd(cwd: string, panes: HerdrPane[], infos: ReadonlyMap, home: string, environOf: (pid: number) => readonly string[] | null = processEnviron): Map { +function omoTranscriptsResolved(cwd: string, panes: readonly PaneResolution[], home: string): Map { const runtimes: OmoRuntime[] = []; // Each process's own store: the default one, and wherever its environment moved it. const agentDirs = new Set([defaultOmoAgentDir(home)]); - const held = new Map(); - /** the session folders a pane's processes hold their sessions in */ - const heldDirs = new Map(); + /** -> the sessions its processes hold: the id, the folder it is held in and its canonical store */ + const held = new Map(); /** for the status only: the choice of session keeps to starts the system itself told */ const since = new Map(); - for (const pane of panes.filter((candidate) => candidate.cwd === cwd)) { - const info = infos.get(pane.pane_id) ?? null; - if (!info?.process_info?.foreground_processes) { runtimes.push({ paneId: pane.pane_id, startedAt: null, paths: [], ids: [] }); continue; } - const processes = (info.process_info?.foreground_processes ?? []).filter((process) => isOmoProcess(process.argv ?? [])); - if (processes.length === 0) continue; - const starts = processes.map((process) => processStartedAt(process.pid)); - const owned = processes.map((process) => omoAgentDir(environOf(process.pid), home, cwd)); + for (const resolved of panes.filter((entry) => inCwdGroup(entry, cwd))) { + const { pane } = resolved; + if (!resolved.informed) { runtimes.push({ paneId: pane.pane_id, startedAt: null, paths: [], ids: [] }); continue; } + // an ambiguous pane claims only through the processes that run in this folder + const omos = resolved.ambiguous ? resolved.omos.filter((omo) => omo.cwd === cwd) : resolved.omos; + if (omos.length === 0) continue; + // every identity came from verified holders alone: hints and inference never apply + const custom = omos.every(({ identity }) => identity.custom); + const starts = omos.map(({ process }) => processStartedAt(process.pid)); + const owned = omos.map(({ identity }) => identity.agentDir); for (const agentDir of owned) agentDirs.add(agentDir); - const dirs = owned.map((agentDir) => sessionDir(cwd, agentDir)); + const dirs = omos.map((omo, index) => sessionDir(omo.cwd, owned[index]!)); + const stores = owned.map((agentDir) => { try { return realpathSync(join(agentDir, "sessions")); } catch { return null; } }); const paths: string[] = []; const ids: string[] = []; - const told = processes.map((process, index) => starts[index] ?? holderStartedAt(dirs[index]!, process.pid)); + const told = omos.map(({ process }, index) => starts[index] ?? holderStartedAt(dirs[index]!, process.pid)); since.set(pane.pane_id, earliestStart(told)); - heldDirs.set(pane.pane_id, dirs); - held.set(pane.pane_id, processes.flatMap((process, index) => heldSessionIds(dirs[index]!, process.pid, starts[index] ?? null))); - for (const [index, process] of processes.entries()) { - ids.push(...resumedIds(process.argv ?? [])); + held.set(pane.pane_id, omos.flatMap(({ identity }, index) => identity.heldIds.map((id) => ({ id, dir: dirs[index]!, store: stores[index] ?? null })))); + for (const [index, { process, identity }] of omos.entries()) { + if (!identity.custom) ids.push(...resumedIds(process.argv ?? [])); // Only an open file in the session store is evidence: omo also holds its background // tasks' logs (/.omo/senpi-task/logs/*.jsonl) open, and one of those pinned the // pane to a path no candidate matches, so the chat lost the transcript mid-session. @@ -279,25 +435,29 @@ export function omoTranscriptsOfCwd(cwd: string, panes: HerdrPane[], infos: Read } } const session = pane.agent_session; - if (session?.agent === "omo" && session.value) { + if (!custom && session?.agent === "omo" && session.value) { if (session.kind === "path") paths.push(session.value); if (session.kind === "id") ids.push(session.value); } - runtimes.push({ paneId: pane.pane_id, startedAt: starts.every((start) => start !== null) ? Math.min(...(starts as number[])) : null, paths, ids }); + runtimes.push({ paneId: pane.pane_id, startedAt: starts.every((start) => start !== null) ? Math.min(...(starts as number[])) : null, paths, ids, stores: stores.filter((store): store is string => store !== null), custom, ambiguous: resolved.ambiguous }); } const files = omoCandidates(cwd, home, runtimes.flatMap((runtime) => runtime.paths), [...agentDirs]); - // Match canonical candidates even when /proc names a symlink into the store. - for (const runtime of runtimes) runtime.paths = runtime.paths.flatMap((path) => { try { return [realpathSync(path)]; } catch { return []; } }); + // Match canonical candidates even when /proc names a symlink into the store, and drop a + // path that no store of this pane contains: it is another pane's or a stale report. + for (const runtime of runtimes) { + runtime.paths = runtime.paths.flatMap((path) => { try { return [realpathSync(path)]; } catch { return []; } }); + if (runtime.stores !== undefined) runtime.paths = runtime.paths.filter((path) => runtime.stores!.some((root) => inStore(path, root))); + } const current = runtimes.map((runtime) => heldRuntime(runtime, held.get(runtime.paneId) ?? [], files)); // That held session's file comes with its first message: until then the pane is a conversation // with nothing in it, not an unreadable one. The folders' names decide too, beside the bounded // candidates; a folder that cannot be listed leaves it untold, as before. const unwritten = (paneId: string): string | null => { - const ids = held.get(paneId) ?? []; - if (ids.length !== 1) return null; - const id = ids[0]!; - if (files.some((file) => file.id === id)) return null; - for (const dir of new Set(heldDirs.get(paneId) ?? [])) { + const entries = held.get(paneId) ?? []; + if (new Set(entries.map((entry) => entry.id)).size !== 1) return null; + const id = entries[0]!.id; + if (files.some((file) => file.id === id && entries.some((entry) => entry.store !== null && entry.store === file.store))) return null; + for (const dir of new Set(entries.map((entry) => entry.dir))) { let names: string[]; try { names = readdirSync(dir); } catch { return null; } // omo names a session's file _.jsonl @@ -306,19 +466,30 @@ export function omoTranscriptsOfCwd(cwd: string, panes: HerdrPane[], infos: Read return id; }; return new Map(current.map((runtime) => { - const pending = unwritten(runtime.paneId); + const pending = runtime.ambiguous ? null : unwritten(runtime.paneId); return [runtime.paneId, { path: pending === null ? selectOmoTranscript(runtime.paneId, files, current) : null, pending, startedAt: runtime.startedAt ?? since.get(runtime.paneId) ?? null }]; })); } +/** The sessions of `cwd`'s panes: each pane's runtime folder is resolved from its own processes first. */ +export function omoTranscriptsOfCwd(cwd: string, panes: HerdrPane[], infos: ReadonlyMap, home: string, environOf: (pid: number) => readonly string[] | null = processEnviron): Map { + return omoTranscriptsResolved(cwd, panes.map((pane) => resolvePaneRuntime(pane, infos.get(pane.pane_id) ?? null, home, environOf)), home); +} + /** * The session an OmO pane holds: its file, or `pending`, the id of a session the pane holds - * but omo has not written yet (before its first message, or right after /new). + * but omo has not written yet (before its first message, or right after /new). The folder + * is the pane's resolved runtime cwd, and its peers are the panes resolving to the same one. */ export async function omoSessionForPane(paneId: string, cwd: string, panes: HerdrPane[], home = process.env["HOME"] ?? ""): Promise<{ path: string | null; pending: string | null }> { - const peers = panes.filter((pane) => pane.cwd === cwd); - const infos = new Map(await Promise.all(peers.map(async (pane) => [pane.pane_id, await processInfo(pane.pane_id)] as const))); - const session = omoTranscriptsOfCwd(cwd, peers, infos, home).get(paneId); + // A child model can change herdr's agent label: inspect each pane's processes once. + const candidates = panes; + const infos = new Map(await Promise.all(candidates.map(async (pane) => [pane.pane_id, await processInfo(pane.pane_id)] as const))); + const resolved = candidates.map((pane) => resolvePaneRuntime(pane, infos.get(pane.pane_id) ?? null, home, processEnviron)); + const target = resolved.find((entry) => entry.pane.pane_id === paneId); + // two holder-owning cwds fail closed: the pane cannot be bound to either + if (target?.ambiguous) return { path: null, pending: null }; + const session = omoTranscriptsResolved(target?.cwd ?? cwd, resolved, home).get(paneId); return { path: session?.path ?? null, pending: session?.pending ?? null }; } @@ -329,17 +500,18 @@ export async function omoTranscriptForPane(paneId: string, cwd: string, panes: H /** * Every pane of a snapshot that runs OmO, whatever herdr calls it, with the session it holds * (null when that cannot be told) and when its OmO process started. One process lookup per - * pane herdr has no other agent for, shared by all the panes of a folder. + * pane, regardless of its child agent label, shared by all panes of a folder; the folder each + * pane counts toward is the one its own processes resolve. */ export async function omoPanes(panes: HerdrPane[], home = process.env["HOME"] ?? ""): Promise> { - const candidates = panes.filter((pane) => !pane.agent || pane.agent === "pi" || pane.agent === "claude" || pane.agent === "omo"); + const candidates = panes; const infos = new Map(await Promise.all(candidates.map(async (pane) => [pane.pane_id, await processInfo(pane.pane_id)] as const))); - const runs = (paneId: string) => (infos.get(paneId)?.process_info?.foreground_processes ?? []).some((process) => isOmoProcess(process.argv ?? [])); + const resolved = candidates.map((pane) => resolvePaneRuntime(pane, infos.get(pane.pane_id) ?? null, home, processEnviron)); const found = new Map(); - for (const cwd of new Set(candidates.filter((pane) => runs(pane.pane_id)).map((pane) => pane.cwd ?? ""))) { - const peers = candidates.filter((pane) => (pane.cwd ?? "") === cwd); - const transcripts = cwd ? omoTranscriptsOfCwd(cwd, peers, infos, home) : new Map(); - for (const pane of peers) if (runs(pane.pane_id)) found.set(pane.pane_id, transcripts.get(pane.pane_id) ?? { path: null, startedAt: null }); + const cwds = new Set(resolved.flatMap((entry) => entry.omos.length === 0 ? [] : entry.ambiguous ? entry.omos.map((omo) => omo.cwd) : [entry.cwd])); + for (const cwd of cwds) { + const transcripts = cwd ? omoTranscriptsResolved(cwd, resolved, home) : new Map(); + for (const entry of resolved) if (entry.omos.length > 0 && inCwdGroup(entry, cwd)) found.set(entry.pane.pane_id, transcripts.get(entry.pane.pane_id) ?? { path: null, startedAt: null }); } return found; } diff --git a/server/omp.test.ts b/server/omp.test.ts index e18a9b838..447192369 100644 --- a/server/omp.test.ts +++ b/server/omp.test.ts @@ -82,7 +82,7 @@ describe("an omp pane's chat", () => { mkdirSync(cwd); pane = { pane_id: "pane-omp", cwd, foreground_cwd: cwd, agent: "omp" } as HerdrPane; const snapshot = spyOn(herdr, "sessionSnapshot").mockImplementation(async () => ({ panes: [pane] } as SessionSnapshot)); - const rpc = spyOn(herdr, "herdrRpc").mockImplementation(async (method) => { + const rpc = spyOn(herdr, "herdrRpc").mockImplementation(async (method: string) => { if (method === "agent.get") return { agent: { agent_session: agentSession } } as never; if (method === "pane.process_info") return { process_info: { foreground_processes: processes } } as never; throw new Error(`unexpected RPC: ${method}`); diff --git a/server/omp.ts b/server/omp.ts index 2f4d01cc0..6923f08b6 100644 --- a/server/omp.ts +++ b/server/omp.ts @@ -6,7 +6,7 @@ */ import { join } from "node:path"; -import { herdrRpc } from "./herdr/client.ts"; +import { paneProcessInfo } from "./herdr/client.ts"; import { heldTranscript, runsAgent } from "./gjc-runtime.ts"; import { processEnviron } from "./omo.ts"; @@ -36,12 +36,10 @@ export function ompAgentDir(argv: readonly string[], environ: readonly string[] /** The transcript the pane's omp process holds open or last pointed its terminal breadcrumb at, or null. */ export async function ompHeldTranscript(paneId: string, cwd: string, home = process.env["HOME"] ?? ""): Promise { - const info = await herdrRpc<{ process_info?: { foreground_processes?: { pid?: unknown; argv?: unknown }[] } }>( - "pane.process_info", { pane_id: paneId }, - ).catch(() => null); + const info = await paneProcessInfo(paneId).catch(() => null); let agentDir: string | null = null; const pids: number[] = []; - for (const process of info?.process_info?.foreground_processes ?? []) { + for (const process of info?.foreground_processes ?? []) { const argv = Array.isArray(process.argv) ? process.argv.map(String) : []; if (typeof process.pid !== "number" || !isOmpProcess(argv)) continue; const dir = ompAgentDir(argv, processEnviron(process.pid), home); diff --git a/server/open-access.test.ts b/server/open-access.test.ts index 8c70382bd..e5402080e 100644 --- a/server/open-access.test.ts +++ b/server/open-access.test.ts @@ -51,6 +51,7 @@ describe("a paired watch device", () => { // paired before the server starts, so its own store holds the device const devices = new DeviceStore(root); const paired = devices.pair(devices.startPairing().code, "Watch", "watch")!; + expect(devices.match(paired.token)).not.toBeNull(); cookie = `herdr_web_device=${encodeURIComponent(paired.token)}`; server = createServer({ port: 0, stateDir: root }); base = `http://127.0.0.1:${server.port}`; @@ -115,6 +116,7 @@ describe("a token set: guessing it", () => { // paired before the server starts, so its own store holds the device const store = new DeviceStore(stateDir); watchToken = store.pair(store.startPairing().code, "Watch", "watch")!.token; + expect(store.match(watchToken)).not.toBeNull(); server = createServer({ port: 0, stateDir, token: TOKEN }); base = `http://127.0.0.1:${server.port}`; }); diff --git a/server/opencode.contract.test.ts b/server/opencode.contract.test.ts index 4d179cb5b..834a1a96d 100644 --- a/server/opencode.contract.test.ts +++ b/server/opencode.contract.test.ts @@ -14,8 +14,10 @@ const root = mkdtempSync(join(tmpdir(), "herdr-web-ui-opencode-contract-")); const database = join(root, "opencode.db"); const fakeOpencode = join(root, "bin", "opencode"); const SESSION = "ses_contractTest01"; +const PAGING_SESSION = "ses_contractPaging01"; const workspaces: string[] = []; let paneId: string; +let pagingPaneId: string; let server: ReturnType; let db: Database; let seq = 0; @@ -30,18 +32,33 @@ const add = (type: string, data: Record, session = SESSION): st .run(id, session, type, seq, Date.now(), Date.now(), JSON.stringify(data)); return id; }; -const turn = (prompt: string, answer: string) => { - add("user", { time: { created: Date.now() }, text: prompt, files: [], agents: [] }); +const turn = (prompt: string, answer: string, session = SESSION) => { + add("user", { time: { created: Date.now() }, text: prompt, files: [], agents: [] }, session); add("assistant", { time: { created: Date.now(), completed: Date.now() }, agent: "build", model: { id: "contract-model", providerID: "test", variant: "high" }, content: [{ type: "text", text: answer }], finish: "stop", tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 20, write: 0 } }, - }); - add("idle", { time: { created: Date.now() }, outcome: "succeeded" }); + }, session); + add("idle", { time: { created: Date.now() }, outcome: "succeeded" }, session); }; /** The pane's session, as herdr's OpenCode integration reports it (herdr-tui-session.js). */ -const reportSession = (session: string) => - herdrRpc("pane.report_agent_session", { pane_id: paneId, source: "herdr:opencode", agent: "opencode", agent_session_id: session, session_start_source: "select" }); +const reportSession = (session: string, targetPaneId = paneId) => + herdrRpc("pane.report_agent_session", { pane_id: targetPaneId, source: "herdr:opencode", agent: "opencode", agent_session_id: session, session_start_source: "select" }); + +const startOpencode = async (session: string): Promise => { + const created = await workspaceCreate({ cwd: root, label: `herdr-web-ui-test-opencode-${session}` }); + workspaces.push(created.workspace.workspace_id); + const targetPaneId = created.root_pane.pane_id; + await herdrRpc("pane.send_text", { pane_id: targetPaneId, text: `${fakeOpencode}\n` }); + for (const deadline = Date.now() + 10_000;;) { + const pane = (await sessionSnapshot()).panes.find((candidate) => candidate.pane_id === targetPaneId); + if (pane?.agent === "opencode") break; + if (Date.now() > deadline) throw new Error("test opencode did not start"); + await Bun.sleep(50); + } + await reportSession(session, targetPaneId); + return targetPaneId; +}; beforeAll(async () => { db = new Database(database, { create: true }); @@ -52,24 +69,18 @@ beforeAll(async () => { CREATE UNIQUE INDEX session_message_session_seq_idx ON session_message (session_id, seq); CREATE INDEX session_message_session_type_seq_idx ON session_message (session_id, type, seq); `); - db.query("INSERT INTO session_v2 (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES (?, 'global', 'contract', ?, 'Contract', '2.0.24', 0, 0)").run(SESSION, root); + for (const session of [SESSION, PAGING_SESSION]) { + db.query("INSERT INTO session_v2 (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES (?, 'global', 'contract', ?, 'Contract', '2.0.24', 0, 0)").run(session, root); + } turn("Check chat", "Answer one"); // herdr takes a session report only from an agent holding the pane: a stand-in OpenCode mkdirSync(join(root, "bin"), { recursive: true }); writeFileSync(fakeOpencode, "#!/bin/sh\nsleep 600\n"); chmodSync(fakeOpencode, 0o755); - const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-opencode-contract" }); - workspaces.push(created.workspace.workspace_id); - paneId = created.root_pane.pane_id; - await herdrRpc("pane.send_text", { pane_id: paneId, text: `${fakeOpencode}\n` }); - for (const deadline = Date.now() + 10_000;;) { - const pane = (await sessionSnapshot()).panes.find((candidate) => candidate.pane_id === paneId); - if (pane?.agent === "opencode") break; - if (Date.now() > deadline) throw new Error("test opencode did not start"); - await Bun.sleep(50); - } - await reportSession(SESSION); + // Keep paging on its own pane as well as session: a timeout does not cancel + // HTTP reads, and the other tests keep writing and switch their pane's session. + [paneId, pagingPaneId] = await Promise.all([startOpencode(SESSION), startOpencode(PAGING_SESSION)]); server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: join(root, "state"), opencodeDb: database }); }); @@ -81,11 +92,11 @@ afterAll(async () => { rmSync(root, { recursive: true, force: true }); }); -const url = (path: string, params: Record) => - `http://127.0.0.1:${server.port}${path}?${new URLSearchParams({ pane_id: paneId, ...params })}`; +const url = (path: string, params: Record, targetPaneId = paneId) => + `http://127.0.0.1:${server.port}${path}?${new URLSearchParams({ pane_id: targetPaneId, ...params })}`; -const read = async (params: Record = {}): Promise => { - const response = await fetch(url("/api/pane/conversation", params)); +const read = async (params: Record = {}, targetPaneId = paneId): Promise => { + const response = await fetch(url("/api/pane/conversation", params, targetPaneId)); expect(response.status).toBe(200); return await response.json() as ConversationResponse; }; @@ -113,14 +124,20 @@ it("serves an OpenCode pane's session over HTTP, and an unchanged one as 304", a }); it("pages a long session, and answers 409 for a cursor from another history", async () => { - for (let n = 0; n < 60; n++) turn(`p${n}`, `a${n}`); - const newest = await read(); + // Batch fixture writes to reduce WAL frame volume, not per-commit fsyncs: + // this store uses WAL + synchronous=NORMAL. + db.transaction(() => { + turn("Check chat", "Answer one", PAGING_SESSION); + turn("Check again", "Answer two", PAGING_SESSION); + for (let n = 0; n < 60; n++) turn(`p${n}`, `a${n}`, PAGING_SESSION); + })(); + const newest = await read({}, pagingPaneId); expect(typeof newest.cursor).toBe("string"); - const older = await read({ before: newest.cursor! }); + const older = await read({ before: newest.cursor! }, pagingPaneId); expect(older.history_id).toBe(newest.history_id); const prompts = [...older.turns, ...newest.turns].filter((entry) => entry.role === "user").map((entry) => (entry.parts[0] as { text: string }).text); expect(prompts).toEqual(["Check chat", "Check again", ...Array.from({ length: 60 }, (_, n) => `p${n}`)]); - const refused = await fetch(url("/api/pane/conversation", { before: `opencode-ses_elsewhere:${newest.cursor!.split(":")[1]}` })); + const refused = await fetch(url("/api/pane/conversation", { before: `opencode-ses_elsewhere:${newest.cursor!.split(":")[1]}` }, pagingPaneId)); expect(refused.status).toBe(409); expect((await refused.json() as { error: { code: string } }).error.code).toBe("history_changed"); }); diff --git a/server/output-window.test.ts b/server/output-window.test.ts index 86e5baf80..1a043108a 100644 --- a/server/output-window.test.ts +++ b/server/output-window.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { OutputWindow, OUTPUT_HIGH_BYTES, OUTPUT_LOW_BYTES, ReplayBuffer } from "./output-window.ts"; +import { OutputWindow, OUTPUT_HARD_BYTES, OUTPUT_HIGH_BYTES, OUTPUT_JOIN_BYTES_PER_SECOND, OUTPUT_JOIN_MAX_MS, OUTPUT_LOW_BYTES, OUTPUT_PAUSE_MAX_MS, OUTPUT_STALL_MS, ReplayBuffer, wireBytes } from "./output-window.ts"; describe("terminal output credit", () => { it("holds high-water backpressure until the low watermark, without duplicate credit", () => { @@ -27,6 +27,198 @@ describe("terminal output credit", () => { expect(current.pending).toBe(0); }); + it("closes a blocked client only after OUTPUT_STALL_MS without ACK progress", () => { + const window = new OutputWindow(); + window.write(OUTPUT_HIGH_BYTES); + expect(window.check(0, 0)).toBe("pause"); + expect(window.check(0, OUTPUT_STALL_MS - 1)).toBe("pause"); + expect(window.check(0, OUTPUT_STALL_MS)).toBe("stop"); + }); + + it("restarts the stall clock on every ACK that moves, never on a duplicate", () => { + const window = new OutputWindow(); + window.write(OUTPUT_HIGH_BYTES * 2); + expect(window.check(0, 0)).toBe("pause"); + // still far above the low watermark, but the client is draining + expect(window.acknowledge(window.id, 64 * 1024)).toBe(true); + expect(window.blocked).toBe(true); + expect(window.check(0, 1500)).toBe("pause"); + expect(window.check(0, 3000)).toBe("pause"); + // the same credit again is no progress + window.acknowledge(window.id, 64 * 1024); + expect(window.check(0, 3499)).toBe("pause"); + expect(window.check(0, 1500 + OUTPUT_STALL_MS)).toBe("stop"); + }); + + it("lets a joiner on a slow link acknowledge a large replay after 3 s (moshi-3)", () => { + const window = new OutputWindow(); + const replay = 200 * 1024; + window.join("x".repeat(replay), 0, 0); + window.write(replay); + window.write(OUTPUT_HIGH_BYTES - replay); // live output on top of it blocks the window + expect(window.blocked).toBe(true); + for (const at of [0, OUTPUT_STALL_MS, 3000]) expect(window.check(0, at)).toBe("flow"); + expect(window.acknowledge(window.id, replay)).toBe(true); + expect(window.joinUntil).toBeUndefined(); + expect(window.blocked).toBe(false); + expect(window.check(0, 3000)).toBe("flow"); + // from here it is an ordinary client again + window.write(OUTPUT_HIGH_BYTES); + expect(window.check(0, 3100)).toBe("pause"); + expect(window.check(0, 3100 + OUTPUT_STALL_MS)).toBe("stop"); + }); + + it("never pauses the shared pty for a joiner, and bounds the time it gets", () => { + const window = new OutputWindow(); + window.join("x".repeat(OUTPUT_HARD_BYTES), 0, 0); + window.write(OUTPUT_HIGH_BYTES + 1024); + // the transport queue counts as blocked too + expect(window.check(OUTPUT_HIGH_BYTES, 1000)).toBe("flow"); + expect(window.joinUntil).toBe(OUTPUT_JOIN_MAX_MS); + expect(window.check(0, OUTPUT_JOIN_MAX_MS - 1)).toBe("flow"); + expect(window.check(0, OUTPUT_JOIN_MAX_MS)).toBe("stop"); + // a full transport queue stops it at once + const full = new OutputWindow(); + full.join("x".repeat(1024), 0, 0); + expect(full.check(OUTPUT_HARD_BYTES, 0)).toBe("stop"); + }); + + it("gives a joiner whose small replay is acknowledged nothing beyond the usual deadline", () => { + const window = new OutputWindow(); + window.join("x".repeat(1024), 0, 0); + window.write(1024); + window.acknowledge(window.id, 1024); + window.write(OUTPUT_HIGH_BYTES); + expect(window.check(0, 10_000)).toBe("pause"); + expect(window.check(0, 10_000 + OUTPUT_STALL_MS)).toBe("stop"); + // and one that never acknowledged anything is out of budget soon after its replay + // (reconcileOutput checks right after the join, which starts its clock) + const silent = new OutputWindow(); + silent.join("x".repeat(1024), 0, 0); + silent.write(1024 + OUTPUT_HIGH_BYTES); + expect(silent.check(0, 0)).toBe("flow"); + expect(silent.check(0, OUTPUT_STALL_MS + 32)).toBe("stop"); + }); + + it("stops a client that holds the pty paused by acknowledging a byte at a time", () => { + const window = new OutputWindow(); + window.write(OUTPUT_HIGH_BYTES); + let at = 0; + let acknowledged = 0; + expect(window.check(0, at)).toBe("pause"); + // each step lands inside the stall deadline, so only the pause cap can end it + while (at + OUTPUT_STALL_MS - 100 < OUTPUT_PAUSE_MAX_MS) { + at += OUTPUT_STALL_MS - 100; + window.acknowledge(window.id, ++acknowledged); + expect(window.check(0, at)).toBe("pause"); + } + expect(window.check(0, OUTPUT_PAUSE_MAX_MS)).toBe("stop"); + // a client that drains to the low watermark in time starts over on its next pause + const honest = new OutputWindow(); + honest.write(OUTPUT_HIGH_BYTES); + expect(honest.check(0, 0)).toBe("pause"); + honest.acknowledge(honest.id, OUTPUT_HIGH_BYTES - OUTPUT_LOW_BYTES); + expect(honest.check(0, OUTPUT_PAUSE_MAX_MS - 1)).toBe("flow"); + honest.write(OUTPUT_HIGH_BYTES); + expect(honest.check(0, OUTPUT_PAUSE_MAX_MS)).toBe("pause"); + expect(honest.check(0, OUTPUT_PAUSE_MAX_MS + 1)).toBe("pause"); + }); + + it("sizes a joiner's budget by its serialized frame, where an ESC costs six bytes", () => { + // a colored-text replay: half of it escape sequences + const replay = "\x1b[31mred\x1b[0m ".repeat(Math.floor(128 * 1024 / 13)); + expect(wireBytes(replay)).toBeGreaterThan(Buffer.byteLength(replay) * 1.5); + const window = new OutputWindow(); + window.join(replay, 0, 0); + window.write(Buffer.byteLength(replay)); + window.write(OUTPUT_HIGH_BYTES); // live output blocks the window meanwhile + expect(window.check(0, 0)).toBe("flow"); + // the frame needs this long on the slowest link the budget allows for, plus the deadline + const crossing = OUTPUT_STALL_MS + Math.ceil(wireBytes(replay) * 1000 / OUTPUT_JOIN_BYTES_PER_SECOND) - 1; + expect(crossing).toBeLessThan(OUTPUT_JOIN_MAX_MS); + expect(window.check(0, crossing)).toBe("flow"); + expect(window.acknowledge(window.id, Buffer.byteLength(replay))).toBe(true); + }); + + it("gives a joiner that was never blocked during its budget the usual deadline later", () => { + const window = new OutputWindow(); + window.join("x".repeat(1024), 0, 0); + window.write(1024); + expect(window.check(0, 0)).toBe("flow"); + // a phone tab suspended right after attaching: never acknowledges, the pane goes busy later + window.write(OUTPUT_HIGH_BYTES); + expect(window.check(0, 20_000)).toBe("pause"); + expect(window.check(0, 20_000 + OUTPUT_STALL_MS - 1)).toBe("pause"); + expect(window.check(0, 20_000 + OUTPUT_STALL_MS)).toBe("stop"); + }); + + it("never pauses or stops an idle pane for another pane's backlog on the same socket", () => { + // two split panes on one socket: this one is quiet and fully acknowledged, the other keeps + // the shared send queue above the high watermark while it joins on a slow link + const idle = new OutputWindow(); + idle.write(10_000); + idle.acknowledge(idle.id, 10_000); + expect(idle.check(300 * 1024, 0)).toBe("flow"); + expect(idle.check(300 * 1024, OUTPUT_STALL_MS + 500)).toBe("flow"); + expect(idle.check(300 * 1024, OUTPUT_PAUSE_MAX_MS + 500)).toBe("flow"); + // the hard transport budget still applies to every pane + expect(idle.check(OUTPUT_HARD_BYTES, OUTPUT_PAUSE_MAX_MS + 600)).toBe("stop"); + // a pane with its own backlog outstanding is still judged by the shared queue + const busy = new OutputWindow(); + busy.write(OUTPUT_LOW_BYTES + 1); + expect(busy.check(300 * 1024, 0)).toBe("pause"); + expect(busy.check(300 * 1024, OUTPUT_STALL_MS)).toBe("stop"); + }); + + it("gives a joiner time for what the socket has queued ahead of its replay", () => { + // a reconnect re-attaches every split pane at once: this replay waits behind another + const replay = "\x1b[31mred\x1b[0m ".repeat(Math.floor(128 * 1024 / 13)); + const ahead = 200 * 1024; + const window = new OutputWindow(); + window.join(replay, ahead, 0); + window.write(Buffer.byteLength(replay)); + window.write(OUTPUT_HIGH_BYTES); // busy: live output blocks the window meanwhile + expect(window.check(0, 0)).toBe("flow"); + const ownFrameOnly = OUTPUT_STALL_MS + Math.ceil(wireBytes(replay) * 1000 / OUTPUT_JOIN_BYTES_PER_SECOND); + const withAhead = Math.min(OUTPUT_JOIN_MAX_MS, OUTPUT_STALL_MS + Math.ceil((ahead + wireBytes(replay)) * 1000 / OUTPUT_JOIN_BYTES_PER_SECOND)); + expect(window.joinUntil).toBe(withAhead); + expect(window.check(0, ownFrameOnly + 500)).toBe("flow"); + expect(window.check(0, withAhead)).toBe("stop"); + }); + + it("gives a large frame still in flight after the replay ACK the time it needs to cross", () => { + const replay = "\x1b[31mred\x1b[0m ".repeat(Math.floor(128 * 1024 / 13)); + const live = "\x1b[32mgrn\x1b[0m ".repeat(Math.floor(64 * 1024 / 13)); + const window = new OutputWindow(); + window.join(replay, 0, 0); + const replayEnd = window.write(Buffer.byteLength(replay), wireBytes(replay)); + // colored live output piles up behind the replay while the joiner never pauses the pty + const firstEnd = window.write(Buffer.byteLength(live), wireBytes(live)); + const lastEnd = window.write(Buffer.byteLength(live), wireBytes(live)); + window.write(4096); + expect(window.blocked).toBe(true); + expect(window.check(0, 0)).toBe("flow"); + // the replay ACK at 4 s leaves two live frames, more than the low watermark, outstanding + expect(window.acknowledge(window.id, replayEnd)).toBe(true); + expect(window.blocked).toBe(true); + expect(window.check(0, 4001)).toBe("pause"); + // the next frame cannot be acknowledged before it has crossed a 32 KiB/s link + expect(wireBytes(live) * 1000 / OUTPUT_JOIN_BYTES_PER_SECOND).toBeGreaterThan(OUTPUT_STALL_MS); + expect(window.check(0, 4001 + OUTPUT_STALL_MS + 100)).toBe("pause"); + expect(window.acknowledge(window.id, firstEnd)).toBe(true); + expect(window.check(0, 7000)).toBe("pause"); + expect(window.acknowledge(window.id, lastEnd)).toBe(true); + expect(window.check(0, 9000)).toBe("flow"); + // and one that never acknowledges it is still stopped once that crossing time is up + const silent = new OutputWindow(); + silent.write(Buffer.byteLength(live), wireBytes(live)); + silent.write(OUTPUT_HIGH_BYTES); + expect(silent.check(0, 0)).toBe("pause"); + const deadline = OUTPUT_STALL_MS + Math.ceil(wireBytes(live) * 1000 / OUTPUT_JOIN_BYTES_PER_SECOND); + expect(silent.check(0, deadline - 1)).toBe("pause"); + expect(silent.check(0, deadline)).toBe("stop"); + }); + it("bounds replay in bytes without corrupting Korean or emoji at the cut", () => { const text = "시작🙂끝"; for (let limit = 1; limit <= Buffer.byteLength(text); limit++) { diff --git a/server/output-window.ts b/server/output-window.ts index ff2cc6511..00b233f7f 100644 --- a/server/output-window.ts +++ b/server/output-window.ts @@ -3,29 +3,122 @@ export const OUTPUT_HIGH_BYTES = 256 * 1024; export const OUTPUT_LOW_BYTES = 64 * 1024; export const OUTPUT_HARD_BYTES = 1024 * 1024; +/** how long a blocked client may go without its ACKs moving before it is closed with 4008 */ export const OUTPUT_STALL_MS = 2000; +/** the slowest link a joiner's replay is given time for: 32 KiB/s, about 256 kbit/s */ +export const OUTPUT_JOIN_BYTES_PER_SECOND = 32 * 1024; +/** the longest a joiner may take to acknowledge its replay */ +export const OUTPUT_JOIN_MAX_MS = 15_000; +/** + * The longest one client may hold the shared pty paused, however it acknowledges: a client + * that ACKs a byte at a time (or a slow phone) would otherwise cap every peer at its own rate. + * Draining 192 KiB from the high to the low watermark takes 6 s at the join rate above. + */ +export const OUTPUT_PAUSE_MAX_MS = 10_000; + +/** + * What a frame carrying `text` costs on the wire: JSON escapes every control byte, so an ESC + * becomes six. Flow credit still counts payload bytes; this only sizes time budgets. + */ +export function wireBytes(text: string): number { + return Buffer.byteLength(JSON.stringify(text)); +} + +/** What the server does about one client: keep sending, pause the shared pty for it, or close it (4008). */ +export type OutputVerdict = "flow" | "pause" | "stop"; export class OutputWindow { readonly id = crypto.randomUUID(); sent = 0; acknowledged = 0; blocked = false; + /** when the client last became blocked without its ACKs moving since */ + private stalledSince: number | undefined; + /** when the client last started holding the shared pty paused, ACKs or not */ + private pausingSince: number | undefined; + /** a joiner's replay is in flight and nothing is acknowledged yet: its budget ends here */ + joinUntil: number | undefined; + /** frames sent and not yet acknowledged, oldest first from `head`: where each ends and its size on the wire */ + private readonly inFlight: { end: number; wire: number }[] = []; + private head = 0; get pending(): number { return this.sent - this.acknowledged; } - write(bytes: number): number { + /** + * `wire` is the frame's serialized size (`wireBytes`): the oldest unacknowledged frame must + * cross the link before its ACK can come, so the stall deadline grows by its crossing time. + */ + write(bytes: number, wire = 0): number { this.sent += bytes; + if (wire > 0) this.inFlight.push({ end: this.sent, wire }); if (this.pending >= OUTPUT_HIGH_BYTES) this.blocked = true; return this.sent; } + /** + * A late joiner is about to be sent `bytes` of replay. Its first ACK can only come once the + * whole frame has crossed the link and been parsed, which takes a phone on a weak link longer + * than the stall deadline (moshi-3): until that ACK, the client gets a budget sized to the + * frame as serialized (`wireBytes`) instead, and is never a reason to pause the pty the other + * clients share. `ahead` is what the socket already has queued in front of the frame (on a + * reconnect every attached pane's replay goes out back to back): it crosses the link first. + */ + join(replay: string, ahead = 0, now = Date.now()): void { + this.joinUntil = now + Math.min(OUTPUT_JOIN_MAX_MS, OUTPUT_STALL_MS + crossingMs(ahead + wireBytes(replay))); + } + acknowledge(id: string, offset: number): boolean { if (id !== this.id || !Number.isSafeInteger(offset) || offset < 0 || offset > this.sent) return false; - // Duplicate and out-of-order ACKs cannot release credit twice. - this.acknowledged = Math.max(this.acknowledged, offset); + // Duplicate and out-of-order ACKs cannot release credit twice, nor restart the stall clock. + if (offset > this.acknowledged) { + this.acknowledged = offset; + this.stalledSince = undefined; + this.joinUntil = undefined; + while (this.head < this.inFlight.length && this.inFlight[this.head]!.end <= offset) this.head++; + if (this.head > 64 && this.head * 2 > this.inFlight.length) { + this.inFlight.splice(0, this.head); + this.head = 0; + } + } if (this.pending <= OUTPUT_LOW_BYTES) this.blocked = false; return true; } + + /** + * Stalls are measured by ACK progress, not by the time since a frame was queued: a client + * that is still acknowledging is draining, however much it has in flight. `buffered` is the + * client's WebSocket send queue, which blocks it as well, unless this window has next to + * nothing outstanding: the queue is shared by every pane on the socket, and a pane with + * nothing to acknowledge can make no ACK progress, so another pane's backlog must not pause + * or stop it (that pane's own window answers for it). The deadline is OUTPUT_STALL_MS plus + * the time the oldest unacknowledged frame needs to cross a slow link: its ACK cannot come + * before then. + */ + check(buffered: number, now = Date.now()): OutputVerdict { + if (buffered >= OUTPUT_HARD_BYTES) return "stop"; + if (!this.blocked && (buffered < OUTPUT_HIGH_BYTES || this.pending <= OUTPUT_LOW_BYTES)) { + this.stalledSince = undefined; + this.pausingSince = undefined; + return "flow"; + } + this.stalledSince ??= now; + if (this.joinUntil !== undefined) { + // within its budget a joiner holds only its own window up (bounded by OUTPUT_HARD_BYTES) + if (now < this.joinUntil) return "flow"; + // past it the joiner is an ordinary client: one blocked through its budget with nothing + // acknowledged is out of time, one that only now became blocked gets the usual deadline + this.joinUntil = undefined; + } + this.pausingSince ??= now; + const oldest = this.inFlight[this.head]; + const deadline = OUTPUT_STALL_MS + (oldest ? Math.min(OUTPUT_JOIN_MAX_MS - OUTPUT_STALL_MS, crossingMs(oldest.wire)) : 0); + return now - this.stalledSince >= deadline || now - this.pausingSince >= OUTPUT_PAUSE_MAX_MS ? "stop" : "pause"; + } +} + +/** how long `wire` bytes take on the slowest link a budget allows for */ +function crossingMs(wire: number): number { + return Math.ceil(wire * 1000 / OUTPUT_JOIN_BYTES_PER_SECOND); } /** DEC private mode switches (CSI ? Pm h / CSI ? Pm l): alternate screen, mouse reporting, ... */ diff --git a/server/output.contract.test.ts b/server/output.contract.test.ts index 60f91cb21..7b4545638 100644 --- a/server/output.contract.test.ts +++ b/server/output.contract.test.ts @@ -66,11 +66,17 @@ async function connect(paneId: string, ack = true, observer = false, port = serv return { ws, state, send, acknowledge }; } -/** Bun.spawn throws synchronously for the pty sidecar while `node` is not on PATH. */ +/** Bun.spawn throws synchronously for the pty sidecar while `node` is not on PATH (herdr itself stays found). */ function breakPath(): () => void { const previous = process.env["PATH"]; + const previousHerdr = process.env["HERDR_WEB_HERDR_BIN"]; + process.env["HERDR_WEB_HERDR_BIN"] = Bun.which(previousHerdr || "herdr", { PATH: previous ?? "" }) ?? previousHerdr ?? "herdr"; process.env["PATH"] = "/nonexistent"; - return () => { process.env["PATH"] = previous; }; + return () => { + process.env["PATH"] = previous; + if (previousHerdr === undefined) delete process.env["HERDR_WEB_HERDR_BIN"]; + else process.env["HERDR_WEB_HERDR_BIN"] = previousHerdr; + }; } async function flood(paneId: string): Promise { @@ -121,6 +127,36 @@ describe("real terminal output flow control", () => { } finally { operator.ws.close(); observer.ws.close(); } }, 35_000); + it("gives a late joiner time to acknowledge its replay without pausing the operator", async () => { + const paneId = await pane(); + const operator = await connect(paneId); + let joiner: Awaited> | undefined; + try { + await flood(paneId); + // the replay tail is full: the joiner's first frame is ~128 KiB + await until(() => operator.state.bytes >= 160 * 1024, "operator fills the replay tail", 15_000); + // a phone on a slow link: it does not acknowledge its replay for longer than the stall deadline + joiner = await connect(paneId, false); + const joined = Date.now(); + expect(joiner.state.bytes).toBeGreaterThan(64 * 1024); + await Bun.sleep(1000); // fixed wait: the joiner must stay silent past the time its window blocks + const before = operator.state.bytes; + await Bun.sleep(OUTPUT_STALL_MS); // fixed wait: proving nothing stalls or closes for a full stall deadline + // the joiner held only its own window up: the shared pty kept streaming to the operator + expect(operator.state.bytes - before).toBeGreaterThan(64 * 1024); + expect(Date.now() - joined).toBeGreaterThan(OUTPUT_STALL_MS); + expect(joiner.state.code).toBe(0); + // its ACK arrives inside its budget and it carries on as an ordinary client + joiner.state.ack = true; + joiner.acknowledge(); + const caught = joiner.state.bytes; + await until(() => joiner!.state.bytes > caught + 4096, "joiner keeps receiving"); + expect(joiner.state.code).toBe(0); + expect(operator.state.code).toBe(0); + operator.send({ type: "input", pane_id: paneId, text: "\x03" }); + } finally { operator.ws.close(); joiner?.ws.close(); } + }, 30_000); + it("resumes on valid ACKs and rejects future credit without accepting stale subscriptions", async () => { const paneId = await pane(); const client = await connect(paneId, false); @@ -173,6 +209,44 @@ describe("a terminal attach that cannot spawn (#154)", () => { }, 30_000); }); +describe("a herdr the server cannot start (#925)", () => { + it("tells the attaching client and logs it, instead of a terminal that waits in silence", async () => { + const paneId = await pane(); + const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws`); + sockets.push(ws); + const errors: string[] = []; + const errorPanes: (string | undefined)[] = []; + ws.addEventListener("message", (event) => { + const frame = JSON.parse(String(event.data)) as ServerMessage; + if (frame.type === "error") { errors.push(frame.code); errorPanes.push(frame.pane_id); } + }); + await until(() => ws.readyState === WebSocket.OPEN, "socket open"); + const missing = join(root, "missing-herdr"); + const logged: string[] = []; + const log = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(" ")); }; + const previous = process.env["HERDR_WEB_HERDR_BIN"]; + process.env["HERDR_WEB_HERDR_BIN"] = missing; + try { + ws.send(JSON.stringify({ type: "attach", pane_id: paneId, cols: 100, rows: 30, flow_control: "ack" } satisfies ClientMessage)); + await until(() => errors.includes("attach_failed"), "the missing herdr is reported", 3_000); + } finally { + console.error = log; + if (previous === undefined) delete process.env["HERDR_WEB_HERDR_BIN"]; + else process.env["HERDR_WEB_HERDR_BIN"] = previous; + } + expect(errors).toEqual(["attach_failed"]); + // the panes of a PC share one connection: the failure names its pane, so only that terminal ends + expect(errorPanes).toEqual([paneId]); + expect(logged.filter((line) => line.includes(paneId) && line.includes(missing))).toHaveLength(1); + // nothing was left behind: with herdr back, the pane attaches for real + const client = await connect(paneId); + try { + expect(client.state.errors).toEqual([]); + } finally { client.ws.close(); ws.close(); } + }, 30_000); +}); + describe("an attach herdr refuses over a read in progress", () => { /** * A herdr that refuses the first `refusals` attaches as herdr 0.9 refuses one racing a diff --git a/server/pane-move.contract.test.ts b/server/pane-move.contract.test.ts deleted file mode 100644 index 13a706dda..000000000 --- a/server/pane-move.contract.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { ApiError, PaneMoved } from "../shared/protocol.ts"; -import { createServer } from "./index.ts"; -import { MACHINE_PROXY_PATH } from "./machine-api.ts"; -import { sessionSnapshot, tabCreate, workspaceClose, workspaceCreate } from "./herdr/client.ts"; - -/** - * POST /api/pane/move against a real herdr: each destination kind, the id a pane gets when it - * leaves its workspace, and the refusals that never reach herdr. Every pane moved here lives in - * a workspace this file created; the workspaces a move makes are closed with them. - */ -describe("POST /api/pane/move", () => { - let server: ReturnType; - const stateDir = mkdtempSync(join(tmpdir(), "herdr-web-ui-pane-move-")); - const owned = new Set(); - const base = () => `http://localhost:${server.port}`; - const post = (body: unknown, path = "/api/pane/move", headers: Record = {}) => fetch(`${base()}${path}`, { - method: "POST", headers: { "content-type": "application/json", ...headers }, body: typeof body === "string" ? body : JSON.stringify(body), - }); - const refused = async (response: Response, status: number, code: string): Promise => { - expect(response.status).toBe(status); - const body = (await response.json()) as ApiError; - expect(body.error.code).toBe(code); - expect(typeof body.error.message).toBe("string"); - }; - const moved = async (response: Response): Promise => { - expect(response.status).toBe(200); - return (await response.json()) as PaneMoved; - }; - - beforeAll(() => { - server = createServer({ port: 0, stateDir }); - }); - - afterAll(async () => { - server?.stop(); - for (const id of owned) await workspaceClose(id).catch(() => undefined); - rmSync(stateDir, { recursive: true, force: true }); - }); - - it("refuses a malformed body before touching herdr", async () => { - expect((await fetch(`${base()}/api/pane/move`)).status).toBe(400); - await refused(await post("{not json"), 400, "invalid_json"); - for (const [body, code] of [ - [{ destination: { type: "new_tab" } }, "missing_pane_id"], - [{ pane_id: "", destination: { type: "new_tab" } }, "missing_pane_id"], - [{ pane_id: 5, destination: { type: "new_tab" } }, "missing_pane_id"], - [{ pane_id: "w:p1" }, "invalid_destination"], - [{ pane_id: "w:p1", destination: "new_tab" }, "invalid_destination"], - [{ pane_id: "w:p1", destination: { type: "sideways" } }, "invalid_destination"], - [{ pane_id: "w:p1", destination: { type: "tab" } }, "invalid_destination"], - [{ pane_id: "w:p1", destination: { type: "tab", tab_id: "w:t1", split: "up" } }, "invalid_destination"], - [{ pane_id: "w:p1", destination: { type: "tab", tab_id: "w:t1", ratio: "half" } }, "invalid_destination"], - [{ pane_id: "w:p1", destination: { type: "new_tab", workspace_id: 3 } }, "invalid_destination"], - [{ pane_id: "w:p1", destination: { type: "new_workspace", label: 7 } }, "invalid_destination"], - [{ pane_id: "w:p1", destination: { type: "new_tab" }, focus: "yes" }, "invalid_focus"], - ] as const) { - await refused(await post(body), 400, code); - } - }); - - it("answers herdr's own refusals in the error envelope", async () => { - const own = await workspaceCreate({ cwd: tmpdir(), label: "herdr-web-ui-test-pane-move-refusals" }); - owned.add(own.workspace.workspace_id); - await refused(await post({ pane_id: "no-such:p1", destination: { type: "new_tab" } }), 404, "pane_not_found"); - await refused(await post({ pane_id: own.root_pane.pane_id, destination: { type: "tab", tab_id: "no-such:t1" } }), 404, "tab_not_found"); - await refused(await post({ pane_id: own.root_pane.pane_id, destination: { type: "new_tab", workspace_id: "no-such" } }), 404, "workspace_not_found"); - }); - - it("moves a pane through every destination kind and reports the id it answers to", async () => { - const a = await workspaceCreate({ cwd: tmpdir(), label: "herdr-web-ui-test-pane-move-a" }); - owned.add(a.workspace.workspace_id); - const b = await workspaceCreate({ cwd: tmpdir(), label: "herdr-web-ui-test-pane-move-b" }); - owned.add(b.workspace.workspace_id); - const second = await tabCreate({ workspaceId: a.workspace.workspace_id }); - const paneId = second.root_pane.pane_id; - - // into another tab of its own workspace, with no split named: the id stays, the emptied tab closes - const intoTab = await moved(await post({ pane_id: paneId, destination: { type: "tab", tab_id: a.tab.tab_id } })); - expect(intoTab.changed).toBe(true); - expect(intoTab.previous_pane_id).toBe(paneId); - expect(intoTab.pane.pane_id).toBe(paneId); - expect(intoTab.pane.tab_id).toBe(a.tab.tab_id); - expect(intoTab.previous_tab_id).toBe(second.tab.tab_id); - expect(intoTab.closed_tab_id).toBe(second.tab.tab_id); - expect(intoTab.target_layout.panes.map((pane) => pane.pane_id)).toContain(paneId); - - // a new tab of its own workspace: still the same id - const ownNewTab = await moved(await post({ pane_id: paneId, destination: { type: "new_tab" } })); - expect(ownNewTab.pane.pane_id).toBe(paneId); - expect(ownNewTab.pane.workspace_id).toBe(a.workspace.workspace_id); - expect(ownNewTab.created_tab?.workspace_id).toBe(a.workspace.workspace_id); - expect(ownNewTab.created_tab?.tab_id).toBe(ownNewTab.pane.tab_id); - - // a new tab of another workspace: a new id, reported beside the old one - const otherNewTab = await moved(await post({ pane_id: paneId, destination: { type: "new_tab", workspace_id: b.workspace.workspace_id } })); - expect(otherNewTab.previous_pane_id).toBe(paneId); - expect(otherNewTab.pane.pane_id).not.toBe(paneId); - expect(otherNewTab.previous_workspace_id).toBe(a.workspace.workspace_id); - expect(otherNewTab.pane.workspace_id).toBe(b.workspace.workspace_id); - expect(otherNewTab.created_tab?.workspace_id).toBe(b.workspace.workspace_id); - expect(otherNewTab.closed_tab_id).toBe(ownNewTab.created_tab?.tab_id); - - // an existing tab of another workspace, split downward - const otherTab = await moved(await post({ pane_id: otherNewTab.pane.pane_id, destination: { type: "tab", tab_id: b.tab.tab_id, split: "down" } })); - expect(otherTab.pane.tab_id).toBe(b.tab.tab_id); - expect(otherTab.closed_tab_id).toBe(otherNewTab.created_tab?.tab_id); - expect(otherTab.target_layout.panes).toHaveLength(2); - - // a workspace of its own - const own = await moved(await post({ pane_id: otherTab.pane.pane_id, destination: { type: "new_workspace", label: "herdr-web-ui-test-pane-move-c" } })); - expect(own.created_workspace?.label).toBe("herdr-web-ui-test-pane-move-c"); - const created = own.created_workspace!.workspace_id; - owned.add(created); - expect(own.pane.pane_id).not.toBe(otherTab.pane.pane_id); - expect(own.pane.workspace_id).toBe(created); - expect(own.created_tab?.workspace_id).toBe(created); - - // herdr knows the pane under its last id only - const snapshot = await sessionSnapshot(); - expect(snapshot.panes.some((pane) => pane.pane_id === own.pane.pane_id)).toBe(true); - for (const old of [paneId, otherNewTab.pane.pane_id]) expect(snapshot.panes.some((pane) => pane.pane_id === old)).toBe(false); - expect(snapshot.workspaces.some((workspace) => workspace.workspace_id === created)).toBe(true); - - // where it already is: nothing changes, and herdr says why - const same = await moved(await post({ pane_id: own.pane.pane_id, destination: { type: "tab", tab_id: own.pane.tab_id } })); - expect(same.changed).toBe(false); - expect(same.reason).toBe("same_tab"); - expect(same.pane.pane_id).toBe(own.pane.pane_id); - - // the local-PC alias reaches it, with focus following this time; the emptied workspace closes behind the pane - expect(MACHINE_PROXY_PATH.test("pane/move")).toBe(true); - const back = await moved(await post( - { pane_id: own.pane.pane_id, destination: { type: "new_tab", workspace_id: a.workspace.workspace_id }, focus: true }, - "/api/machines/local/pane/move", - { "x-herdr-machine": "1" }, - )); - expect(back.pane.workspace_id).toBe(a.workspace.workspace_id); - expect(back.closed_workspace_id).toBe(created); - expect(back.focused_pane_id).toBe(back.pane.pane_id); - expect((await sessionSnapshot()).focused_pane_id).toBe(back.pane.pane_id); - owned.delete(created); - }, 30_000); -}); diff --git a/server/pane-move.ts b/server/pane-move.ts deleted file mode 100644 index 5c588fe77..000000000 --- a/server/pane-move.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * POST /api/pane/move's body, read before herdr is asked. herdr refuses a malformed destination - * with a generic `invalid_request`, so its shape is named here as `invalid_destination` instead, - * and only the fields herdr's PaneMoveDestination has are passed on: a tab destination gets the - * split it needs (right) when the caller left it out, and herdr's own focus is left alone - * unless the caller asked for it. - */ -import type { PaneMoveParams } from "../shared/herdr-api.generated.ts"; -import type { MovePaneDestination } from "../shared/protocol.ts"; -import { isJsonObject } from "./http.ts"; - -export interface MoveRequestProblem { - code: "missing_pane_id" | "invalid_destination" | "invalid_focus"; - message: string; -} - -const optionalText = (value: unknown): value is string | null | undefined => value === undefined || value === null || typeof value === "string"; - -/** a label-like field as herdr takes it: absent and null are the same, an empty string is kept as given */ -const label = (value: string | null | undefined): { label?: string | null } => (value === undefined ? {} : { label: value }); - -function parseDestination(value: unknown): MovePaneDestination | string { - if (!isJsonObject(value)) return "destination must be an object"; - switch (value["type"]) { - case "tab": { - const tabId = value["tab_id"]; - if (typeof tabId !== "string" || tabId.trim() === "") return "destination.tab_id is required for a tab destination"; - const split = value["split"] ?? "right"; - if (split !== "right" && split !== "down") return "destination.split must be \"right\" or \"down\""; - const target = value["target_pane_id"]; - if (!optionalText(target)) return "destination.target_pane_id must be a string"; - const ratio = value["ratio"]; - if (ratio !== undefined && ratio !== null && (typeof ratio !== "number" || !Number.isFinite(ratio))) return "destination.ratio must be a number"; - return { - type: "tab", - tab_id: tabId, - split, - ...(target === undefined ? {} : { target_pane_id: target }), - ...(ratio === undefined ? {} : { ratio: ratio as number | null }), - }; - } - case "new_tab": { - const workspaceId = value["workspace_id"]; - if (!optionalText(workspaceId) || workspaceId?.trim() === "") return "destination.workspace_id must be a workspace id"; - if (!optionalText(value["label"])) return "destination.label must be a string"; - return { type: "new_tab", ...(workspaceId === undefined ? {} : { workspace_id: workspaceId }), ...label(value["label"] as string | null | undefined) }; - } - case "new_workspace": { - if (!optionalText(value["label"])) return "destination.label must be a string"; - if (!optionalText(value["tab_label"])) return "destination.tab_label must be a string"; - const tabLabel = value["tab_label"] as string | null | undefined; - return { type: "new_workspace", ...label(value["label"] as string | null | undefined), ...(tabLabel === undefined ? {} : { tab_label: tabLabel }) }; - } - default: - return "destination.type must be \"tab\", \"new_tab\" or \"new_workspace\""; - } -} - -export function parseMoveRequest(payload: Record): { params: PaneMoveParams } | { problem: MoveRequestProblem } { - const paneId = payload["pane_id"]; - if (typeof paneId !== "string" || paneId.trim() === "") return { problem: { code: "missing_pane_id", message: "pane_id is required" } }; - const destination = parseDestination(payload["destination"]); - if (typeof destination === "string") return { problem: { code: "invalid_destination", message: destination } }; - const focus = payload["focus"]; - if (focus !== undefined && typeof focus !== "boolean") return { problem: { code: "invalid_focus", message: "focus must be a boolean" } }; - return { params: { pane_id: paneId, destination, focus: focus === true } }; -} diff --git a/server/paste.ts b/server/paste.ts index 4c8b6f213..f34bdc766 100644 --- a/server/paste.ts +++ b/server/paste.ts @@ -21,6 +21,47 @@ import { MAX_ATTACHMENT_BYTES } from "../shared/attachments.ts"; /** Decode ceiling: the limit the browser checks a file against before it uploads. */ export const MAX_IMAGE_BYTES = MAX_ATTACHMENT_BYTES; +/** The base64 text of a file at the ceiling, with the one padding group a decoder may add. */ +const MAX_ENCODED_BYTES = Math.ceil(MAX_IMAGE_BYTES / 3) * 4 + 4; + +/** What the JSON around the data may add: pane_id, content_type and a file name. */ +const ENVELOPE_ALLOWANCE = 1024 * 1024; + +/** The largest request body the image route reads; Bun.serve's `maxRequestBodySize` sits well above it. */ +export const MAX_IMAGE_BODY_BYTES = MAX_ENCODED_BYTES + ENVELOPE_ALLOWANCE; + +function tooLarge(): PasteImageError { + return new PasteImageError("image_too_large", `file exceeds ${MAX_IMAGE_BYTES} bytes`, 413); +} + +/** + * The request body as text, refused past `MAX_IMAGE_BODY_BYTES`: by its announced length before any + * of it is read, and by a running byte count for a body that announces none (chunked). Bun's own + * limit answers an empty 413 the browser cannot explain, so this one answers first with the reason. + */ +export async function readImageBody(request: Request): Promise { + const announced = Number(request.headers.get("content-length")); + if (Number.isFinite(announced) && announced > MAX_IMAGE_BODY_BYTES) { + void request.body?.cancel().catch(() => undefined); + throw tooLarge(); + } + if (request.body === null) return ""; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_IMAGE_BODY_BYTES) { + void reader.cancel().catch(() => undefined); + throw tooLarge(); + } + chunks.push(value); + } + return Buffer.concat(chunks).toString("utf8"); +} + /** * A file that is not one of the image types keeps its own name (sanitised), so the agent * reading the path sees what it is: an icon.svg, a notes.pdf, a data.csv. @@ -82,16 +123,12 @@ export async function savePaneImage(options: { const named = imageExtension === undefined ? storedName(options.name) : null; // cheap pre-check: reject the ENCODED length before allocating the decode buffer, // so an oversized body never costs a second copy of itself in memory - if (options.dataBase64.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4 + 4) { - throw new PasteImageError("image_too_large", `file exceeds ${MAX_IMAGE_BYTES} bytes`, 413); - } + if (options.dataBase64.length > MAX_ENCODED_BYTES) throw tooLarge(); const data = Buffer.from(options.dataBase64, "base64"); if (data.byteLength === 0) { throw new PasteImageError("empty_image", "file is empty", 400); } - if (data.byteLength > MAX_IMAGE_BYTES) { - throw new PasteImageError("image_too_large", `file exceeds ${MAX_IMAGE_BYTES} bytes`, 413); - } + if (data.byteLength > MAX_IMAGE_BYTES) throw tooLarge(); const pane = await paneGet(options.paneId); diff --git a/server/pending-input.contract.test.ts b/server/pending-input.contract.test.ts index 5b54bc8d4..2820f1b2d 100644 --- a/server/pending-input.contract.test.ts +++ b/server/pending-input.contract.test.ts @@ -1,12 +1,17 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it, mock } from "bun:test"; -import { chmodSync, copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { afterAll, afterEach, beforeAll, describe, expect, it, mock, spyOn } from "bun:test"; +import { chmodSync, copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createServer } from "./index.ts"; import * as herdrClient from "./herdr/client.ts"; import { herdrRpc, paneRead, paneScrollInfo, sessionSnapshot } from "./herdr/client.ts"; import { noteSubmitted, parseInteractivePrompt } from "./prompt.ts"; -import type { PendingMessage } from "../shared/protocol.ts"; +import type { PendingMessage, ServerMessage } from "../shared/protocol.ts"; +import * as processStart from "./process-start.ts"; +import * as pendingInput from "./pending-input.ts"; +import { OmoStatus } from "./omo-status.ts"; + +type PendingUpdate = Extract; const root = mkdtempSync(join(tmpdir(), "herdr-web-ui-pending-")); const active: Array<{ stop: () => void; sockets: Socket[]; workspace: string }> = []; @@ -54,6 +59,20 @@ class Socket { await Bun.sleep(25); } } + /** Subscribe before triggering a state transition; retain already received frames. */ + event(predicate: (frame: PendingUpdate) => boolean, from = 0): Promise { + const found = this.seen.slice(from).find((frame) => frame.type === "pending-messages" && predicate(frame)); + if (found) return Promise.resolve(found); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { this.ws.removeEventListener("message", received); reject(new Error("pending event timeout")); }, 15_000); + const received = (event: MessageEvent) => { + const frame: ServerMessage = JSON.parse(String(event.data)); + if (frame.type !== "pending-messages" || !predicate(frame)) return; + clearTimeout(timeout); this.ws.removeEventListener("message", received); resolve(frame); + }; + this.ws.addEventListener("message", received); + }); + } send(frame: unknown) { this.ws.send(JSON.stringify(frame)); } result(id: number) { return this.wait((frame) => frame.type === "submit-result" && frame.id === id); } action(id: number) { return this.wait((frame) => frame.type === "pending-result" && frame.id === id); } @@ -77,7 +96,7 @@ afterEach(async () => { }); afterAll(() => rmSync(root, { recursive: true, force: true })); -async function setup(label: string, agent = "claude", screen = "› Message\n", options: Parameters[0] = {}) { +async function setup(label: string, agent = "claude", screen = "› Message\n", options: Parameters[0] = {}, replaceable = false) { const id = ++next; const created = await herdrRpc<{ workspace: { workspace_id: string }; root_pane: { pane_id: string } }>("workspace.create", { label: `herdr-web-ui-test-pending-${label}`, cwd: root, focus: false }); // registered before anything below can throw, so afterEach closes the workspace of a setup that failed @@ -86,7 +105,12 @@ async function setup(label: string, agent = "claude", screen = "› Message\n", const log = join(root, `${id}.jsonl`); const screenFile = join(root, `${id}.screen`); writeFileSync(screenFile, ""); - await herdrRpc("pane.send_text", { pane_id: pane, text: `exec '${join(root, agent)}' '${join(root, "record.cjs")}' '${log}' '${screen.replace(/\n/g, "\r\n")}' '${screenFile}'\n` }); + const command = `'${join(root, agent)}' '${join(root, "record.cjs")}' '${log}' '${screen.replace(/\n/g, "\r\n")}' '${screenFile}'`; + // The shell immediately starts SESSION2 once SESSION1 exits, preserving terminal and lease. + const replacementLog = join(root, `${id}-replacement.jsonl`); + await herdrRpc("pane.send_text", { pane_id: pane, text: replaceable + ? `${command}; exec '${join(root, agent)}' '${join(root, "record.cjs")}' '${replacementLog}' 'SESSION2' '${screenFile}'\n` + : `exec ${command}\n` }); const deadline = Date.now() + 10_000; while (!existsSync(log)) { if (Date.now() > deadline) throw new Error("recorder did not start"); await Bun.sleep(25); } // "plain" is a program herdr knows as no agent @@ -123,10 +147,197 @@ async function setup(label: string, agent = "claude", screen = "› Message\n", }; const removed = (id: string, outcome = "sent", reader = socket) => reader.wait((frame) => frame.type === "pending-messages" && frame.pane_id === pane && frame.removed?.some((entry: any) => entry.id === id && entry.outcome === outcome)); const another = async (attach = true) => { const reader = new Socket(server.port); entry.sockets.push(reader); if (attach) await reader.attach(pane); else await reader.wait((frame) => frame.type === "snapshot"); return reader; }; - return { pane, port: server.port, socket, bytes, waitBytes, state, showScreen, queue, removed, another }; + return { screenFile, pane, port: server.port, socket, bytes, waitBytes, state, showScreen, queue, removed, another, replacementLog }; } describe("connection-owned pending input", () => { + it("holds a queued message when a hookless agent process is replaced in the same terminal (regression #540)", async () => { + const f = await setup("process-replaced", "claude", "SESSION1", {}, true); + const before = await herdrClient.paneGet(f.pane); + const process = (await herdrClient.paneProcessInfo(f.pane))?.foreground_processes?.find((entry) => entry.name === "claude"); + if (!process) throw new Error("SESSION1 has no foreground claude process"); + const message = await f.queue(540, "MSG-FOR-SESSION-1"); + expect(before.agent_session ?? null).toBeNull(); // control: no native session distinguishes the processes + expect(f.bytes()).toBe(""); // control: enqueue never types while working + const started = new Promise((resolve, reject) => { + const watcher = watch(root, () => { + if (!existsSync(f.replacementLog)) return; + clearTimeout(timeout); watcher.close(); resolve(); + }); + const timeout = setTimeout(() => { watcher.close(); reject(new Error("SESSION2 did not start")); }, 10_000); + }); + globalThis.process.kill(process.pid, "SIGKILL"); + await started; + const after = await herdrClient.paneGet(f.pane); + const replacement = (await herdrClient.paneProcessInfo(f.pane))?.foreground_processes?.find((entry) => entry.name === "claude"); + expect(after.terminal_id).toBe(before.terminal_id); // control: same terminal and attach + expect(replacement).toBeDefined(); // control: SESSION2 is a live foreground agent + expect(replacement?.pid).not.toBe(process.pid); // control: the process really changed + const settled = f.socket.event((frame) => frame.removed?.some((entry) => entry.id === message.id) + || frame.messages.some((entry) => entry.id === message.id && entry.state === "held")); + await f.state("idle"); + const outcome = await settled; + const bytes = readFileSync(f.replacementLog, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line) as string).join(""); + expect(outcome.messages).toContainEqual(expect.objectContaining({ + id: message.id, state: "held", error: expect.objectContaining({ code: "pending_target_changed" }), + })); // regression: replacement is held instead of automatic delivery + expect(bytes).not.toContain("MSG-FOR-SESSION-1"); // regression: SESSION2 receives nothing + }, 30_000); + + it("delivers to the same hookless process with unchanged pid and birth (control #540)", async () => { + const f = await setup("same-process"); + const before = (await herdrClient.paneProcessInfo(f.pane))?.foreground_processes?.[0]; + if (!before) throw new Error("No foreground recorder"); + const birth = processStart.processStartedAt(before.pid); + const message = await f.queue(541, "same process"); + const removed = f.socket.event((frame) => frame.removed?.some((entry) => entry.id === message.id) === true); + await f.state("idle"); await removed; await f.waitBytes("\r"); + const after = (await herdrClient.paneProcessInfo(f.pane))?.foreground_processes?.[0]; + expect(after?.pid).toBe(before.pid); // control + const afterBirth = processStart.processStartedAt(before.pid); + if (globalThis.process.platform === "darwin" && birth !== null && afterBirth !== null) { + expect(Math.abs(afterBirth - birth)).toBeLessThanOrEqual(1000); // control: macOS precision + } else expect(afterBirth).toBe(birth); // control: Linux birth is exact + expect(f.bytes()).toBe(`${paste("same process")}\r`); // control + }, 30_000); + + for (const [drift, delivered] of [[600, true], [1500, false]] as const) { + it(`${delivered ? "delivers" : "holds"} a same-pid message with ${drift} ms birth drift on darwin (control #540)`, async () => { + const f = await setup(`darwin-birth-${drift}`); + const process = (await herdrClient.paneProcessInfo(f.pane))?.foreground_processes?.[0]; + if (!process) throw new Error("No foreground recorder"); + let reading = 100_000; + const birth = processStart.processStartedAt; + const compare = pendingInput.samePendingIdentity; + // Supply macOS's two observations without changing the real recorder, RPCs or delivery. + const startedAt = spyOn(processStart, "processStartedAt").mockImplementation((pid) => pid === process.pid ? reading : birth(pid)); + const platform = spyOn(pendingInput, "samePendingIdentity").mockImplementation((before, after) => compare(before, after, "darwin")); + try { + const message = await f.queue(544, `darwin birth ${drift}`); + reading += drift; + const settled = f.socket.event((frame) => frame.removed?.some((entry) => entry.id === message.id) + || frame.messages.some((entry) => entry.id === message.id && entry.state === "held")); + await f.state("idle"); + const outcome = await settled; + if (delivered) { + expect(outcome.removed).toContainEqual({ id: message.id, outcome: "sent" }); // control: tolerated 600 ms drift + await f.waitBytes("\r"); + expect(f.bytes()).toBe(`${paste(message.text)}\r`); // control: actual delivery + } else { + expect(outcome.messages).toContainEqual(expect.objectContaining({ + id: message.id, state: "held", error: expect.objectContaining({ code: "pending_target_changed" }), + })); // control: 1500 ms is a different birth, even on the same PID + expect(f.bytes()).toBe(""); // control: nothing was delivered + } + } finally { platform.mockRestore(); startedAt.mockRestore(); } + }, 30_000); + } + + it("delivers when an initially unknown native session becomes known (regression #540)", async () => { + const f = await setup("session-upgrade"); + const message = await f.queue(542, "session became known"); + const snapshot = herdrClient.sessionSnapshot; + const upgraded = spyOn(herdrClient, "sessionSnapshot").mockImplementation(async (...args) => { + const current = await snapshot(...args); + return { ...current, panes: current.panes.map((pane) => pane.pane_id === f.pane + ? { ...pane, agent_session: { kind: "session_id", source: "cli", value: "now-known", agent: "claude" } } : pane) }; + }); + try { + const settled = f.socket.event((frame) => frame.removed?.some((entry) => entry.id === message.id) + || frame.messages.some((entry) => entry.id === message.id && entry.state === "held")); + await f.state("idle"); + const outcome = await settled; + expect(outcome.removed ?? []).toContainEqual({ id: message.id, outcome: "sent" }); // regression: learning identity is not replacement + await f.waitBytes("\r"); + expect(f.bytes()).toBe(`${paste("session became known")}\r`); // control: exactly one delivery + } finally { upgraded.mockRestore(); } + }, 30_000); + + it("holds a message when OmO reports a different session on the same process (regression #540)", async () => { + const f = await setup("omo-session-changed"); + let sessionId = "omo-first"; + const session = spyOn(OmoStatus.prototype, "sessionOf").mockImplementation((paneId) => paneId === f.pane ? { cwd: root, sessionId } : null); + try { + const message = await f.queue(543, "for the first omo session"); + sessionId = "omo-second"; + const settled = f.socket.event((frame) => frame.messages.some((entry) => entry.id === message.id && entry.state === "held") + || frame.removed?.some((entry) => entry.id === message.id) === true); + await f.state("idle"); + expect((await settled).messages).toContainEqual(expect.objectContaining({ + id: message.id, state: "held", error: expect.objectContaining({ code: "pending_target_changed" }), + })); // regression: native OmO session is part of pending identity + expect(f.bytes()).toBe(""); // regression: the new session receives nothing + } finally { session.mockRestore(); } + }, 30_000); + + for (const originalFirst of [false, true]) it(`regression: two connections share refusal then retry a new attempt once (original first=${originalFirst})`, async () => { + const rule = "\u2500".repeat(60), footer = "[Haiku 4.5] \u2502 project"; + const f = await setup("copy-retry", "claude", ` \u273b Cooked for 1s\n${rule}\n\u276f occupied draft\n${rule}\n ${footer}\n`); + let resendId = "retry-copy-id"; + if (originalFirst) { + f.socket.send({ type: "submit", id: 0, pane_id: f.pane, text: "retry copy", payload: "unused", delivery: "hold" }); + const held = await f.socket.result(0); + expect(held.pending?.state).toBe("held"); + resendId = held.pending.id; + } + await f.state("idle"); + const other = await f.another(); + const send = (socket: Socket, id: number, generation: number) => socket.send(originalFirst && socket === f.socket + ? { type: "pending-action", id, pane_id: f.pane, pending_id: resendId, action: "steer", resend_generation: generation } + : { type: "submit", id, pane_id: f.pane, text: "retry copy", payload: paste("retry copy"), resend_id: resendId, resend_generation: generation }); + const originalResult = (id: number) => originalFirst ? f.socket.action(id) : f.socket.result(id); + send(f.socket, 1, 0); send(other, 2, 0); + expect(await originalResult(1)).toMatchObject({ ok: false, code: "input_draft" }); + expect(await other.result(2)).toMatchObject({ ok: false, code: "input_draft" }); + let output = ""; + const processed = new Set(); + const drawn = f.socket.wait((frame) => { + if (frame.type !== "pty-data" || processed.has(frame)) return false; + processed.add(frame); output += frame.data; + return output.includes("run the tests"); + }, f.socket.seen.length); + writeFileSync(f.screenFile, ` \u273b Cooked for 1s\n${rule}\n\u276f \u001b[2mrun the tests\u001b[0m\n${rule}\n ${footer} \n`); + await drawn; + send(other, 3, 0); + expect(await other.result(3)).toMatchObject({ ok: false, code: "input_draft" }); + send(f.socket, 4, 1); send(other, 5, 1); + expect(await originalResult(4)).toMatchObject({ ok: true }); + expect(await other.result(5)).toMatchObject({ ok: true }); + const barrier = new Promise((resolve, reject) => { + const watcher = watch(root, () => { + if (f.bytes().includes("retry-barrier\r")) { clearTimeout(timer); watcher.close(); resolve(); } + }); + const timer = setTimeout(() => { watcher.close(); reject(new Error("recorder barrier timed out")); }, 5000); + }); + other.send({ type: "submit", id: 6, pane_id: f.pane, text: "retry-barrier", payload: "retry-barrier", typed: true }); + expect(await other.result(6)).toMatchObject({ ok: true }); + await barrier; + expect(f.bytes().split("retry copy").length - 1).toBe(1); + }, 30_000); + for (const copyFirst of [false, true]) it(`regression: shares a held row's send with its saved copy (copy first=${copyFirst})`, async () => { + const f = await setup("cross-tab-held"); + f.socket.send({ type: "submit", id: 1, pane_id: f.pane, text: "held cross tab", payload: "unused", delivery: "hold" }); + const answer = await f.socket.result(1); + expect(answer.pending?.state).toBe("held"); + await f.state("idle"); + const other = await f.another(); + const copy = () => other.send({ type: "submit", id: 2, pane_id: f.pane, text: "held cross tab", payload: paste("held cross tab"), resend_id: answer.pending.id }); + const original = () => f.socket.send({ type: "pending-action", id: 3, pane_id: f.pane, pending_id: answer.pending.id, action: "steer" }); + if (copyFirst) { copy(); expect(await other.result(2)).toMatchObject({ ok: true }); original(); } + else { original(); expect(await f.socket.action(3)).toMatchObject({ ok: true }); copy(); } + expect(await other.result(2)).toMatchObject({ ok: true }); + expect(await f.socket.action(3)).toMatchObject({ ok: true }); + const barrier = new Promise((resolve, reject) => { + const watcher = watch(root, () => { + if (f.bytes().includes("barrier\r")) { clearTimeout(timer); watcher.close(); resolve(); } + }); + const timer = setTimeout(() => { watcher.close(); reject(new Error("recorder barrier timed out")); }, 5000); + }); + other.send({ type: "submit", id: 4, pane_id: f.pane, text: "barrier", payload: "barrier", typed: true }); + expect(await other.result(4)).toMatchObject({ ok: true }); + await barrier; + expect(f.bytes().split("held cross tab").length - 1).toBe(1); + }, 30_000); it("interrupts an explicit Codex Send now only after its native steering panel appears", async () => { const f = await setup("codex-send-now", "codex"); const message = await f.queue(1, "send this now"); f.socket.send({ type: "pending-action", id: 10, pane_id: f.pane, pending_id: message.id, action: "steer" }); @@ -557,9 +768,9 @@ describe("connection-owned pending input", () => { expect(f.bytes()).toBe(""); expect(f.socket.seen.some((frame) => frame.type === "pending-messages")).toBe(false); }, 30_000); - it("checks a new blocked state after the Stop key's settling gap, before pasting", async () => { - const f = await setup("stop-gap"); const message = await f.queue(1, "must not paste"); - f.socket.send({ type: "input", pane_id: f.pane, text: "\u001b" }); + for (const stop of [false, true]) it(`checks a new blocked state after the ${stop ? "composer's Stop" : "typed ESC"}'s settling gap, before pasting`, async () => { + const f = await setup(stop ? "stop-gap-intent" : "stop-gap"); const message = await f.queue(1, "must not paste"); + f.socket.send(stop ? { type: "input", pane_id: f.pane, text: "\u001b", stop: true } : { type: "input", pane_id: f.pane, text: "\u001b" }); f.socket.send({ type: "pending-action", id: 10, pane_id: f.pane, pending_id: message.id, action: "steer" }); await f.socket.wait((frame) => frame.type === "pending-messages" && frame.messages.some((item: PendingMessage) => item.id === message.id && item.state === "sending")); await f.state("blocked"); @@ -579,7 +790,8 @@ describe("connection-owned pending input", () => { const first = await f.queue(1, "first"); const second = await f.queue(2, "held second"); await f.state("idle"); await f.removed(first.id); await f.socket.wait((frame) => frame.type === "pending-messages" && frame.messages.some((item: PendingMessage) => item.id === second.id && item.state === "held" && item.error?.code === "pending_turn_unconfirmed")); - expect(f.bytes()).toBe(`${paste("first")}\r`); + // the bridge's Enter has gone to herdr, but the recorder in the pane may not have logged it yet + await f.waitBytes("\r"); expect(f.bytes()).toBe(`${paste("first")}\r`); }, 30_000); it("rejects ordinary queue input and Send now at a visible password prompt despite a working state", async () => { diff --git a/server/pending-input.test.ts b/server/pending-input.test.ts index e0191767e..ecc3b2de8 100644 --- a/server/pending-input.test.ts +++ b/server/pending-input.test.ts @@ -1,5 +1,33 @@ import { describe, expect, it } from "bun:test"; -import { MAX_PENDING_PER_OWNER, PendingInputs } from "./pending-input.ts"; +import { MAX_PENDING_PER_OWNER, PendingInputs, samePendingIdentity, type PendingIdentity } from "./pending-input.ts"; +import { PendingMessageStore } from "../src/lib/pendingMessages.ts"; + +describe("pending target identity", () => { + const identity: PendingIdentity = { agent: "claude", terminalId: "t1", session: null, processPid: 12, processBirth: 100_000, omoSession: null }; + + it("refuses known process, birth, native session and OmO session replacements (regression #540)", () => { + expect(samePendingIdentity(identity, { ...identity, processPid: 13 })).toBe(false); + expect(samePendingIdentity(identity, { ...identity, processBirth: 110_000 })).toBe(false); + expect(samePendingIdentity({ ...identity, session: "old" }, { ...identity, session: "new" })).toBe(false); + expect(samePendingIdentity({ ...identity, omoSession: "old" }, { ...identity, omoSession: "new" })).toBe(false); + }); + + it("permits identity parts that were unknown on either read (control #540)", () => { + const unknown: PendingIdentity = { agent: "claude", terminalId: "t1", session: null }; + const known = { ...identity, session: "native", omoSession: "omo" }; + expect(samePendingIdentity(unknown, known)).toBe(true); + expect(samePendingIdentity(known, unknown)).toBe(true); + expect(samePendingIdentity(identity, { ...identity })).toBe(true); + expect(samePendingIdentity(identity, { ...identity, terminalId: "t2" })).toBe(false); + expect(samePendingIdentity(identity, { ...identity, agent: null })).toBe(false); + }); + + it("tolerates only the existing macOS birth precision (control #540)", () => { + expect(samePendingIdentity(identity, { ...identity, processBirth: 101_000 }, "darwin")).toBe(true); + expect(samePendingIdentity(identity, { ...identity, processBirth: 101_001 }, "darwin")).toBe(false); + expect(samePendingIdentity(identity, { ...identity, processBirth: 101_000 }, "linux")).toBe(false); + }); +}); function fixture() { let now = 1_000; @@ -11,6 +39,54 @@ function fixture() { } describe("bridge pending input", () => { + it("regression: shares one resend outcome across stale cross-tab reads", async () => { + const f = fixture(); + let finish!: (value: { ok: boolean }) => void; + const completion = new Promise<{ ok: boolean }>((resolve) => { finish = resolve; }); + const submitted: string[] = []; + const submit = () => { submitted.push("copy-id"); return completion; }; + let saved = JSON.stringify({ messages: [{ id: "copy-id", request_id: 1, text: "text", state: "held", created_at: "2026-10-14T00:00:00Z" }] }); + const snapshot = saved; + const port = { getItem: () => snapshot, setItem: (_key: string, raw: string) => { saved = raw; }, removeItem: () => {} }; + const a = new PendingMessageStore(() => port), b = new PendingMessageStore(() => port); + // A read, B read, A write, B write: localStorage cannot make this claim atomic. + const firstCopy = a.read("p")[0]!, secondCopy = b.read("p")[0]!; + a.begin("p", firstCopy.id); b.begin("p", secondCopy.id); + expect(a.unconfirm("p", firstCopy.id)).toBe(true); + expect(b.unconfirm("p", secondCopy.id)).toBe(true); + const first = f.queue.resend("p", firstCopy.id, firstCopy.text, submit); + const second = f.queue.resend("p", secondCopy.id, secondCopy.text, submit); + await Promise.resolve(); + finish({ ok: true }); + expect(await Promise.all([first, second])).toEqual([{ ok: true }, { ok: true }]); + expect(submitted).toEqual(["copy-id"]); + }); + it("control: retains refused outcomes and rejects a different message for the same resend id", async () => { + const f = fixture(); + expect(await f.queue.resend("p", "id", "one", async () => ({ ok: false, code: "agent_blocked" }))).toEqual({ ok: false, code: "agent_blocked" }); + expect(await f.queue.resend("p", "id", "one", async () => ({ ok: true }))).toEqual({ ok: false, code: "agent_blocked" }); + expect(() => f.queue.resend("p", "id", "two", async () => ({ ok: true }))).toThrow("another message"); + expect(await f.queue.resend("p", "id", "one", async () => ({ ok: true }), 1)).toEqual({ ok: true }); + }); + it("control: retains an in-flight resend past the lifetime and expires only settled receipts", async () => { + const f = fixture(); + let finish!: (value: { ok: boolean }) => void; + const first = f.queue.resend("p", "id", "text", () => new Promise((resolve) => { finish = resolve; })); + await Promise.resolve(); + f.advance(60_001); + expect(f.queue.resend("p", "id", "text", async () => ({ ok: false }))).toBe(first); + finish({ ok: true }); + await first; + f.advance(60_001); + expect(await f.queue.resend("p", "id", "text", async () => ({ ok: false }))).toEqual({ ok: false }); + }); + it("control: keeps the resend receipt while its original live pending row still exists", async () => { + const f = fixture(), item = f.enqueue(1); + const first = f.queue.resend("p1", item.message.id, "fingerprint", async () => ({ ok: true })); + await first; + f.advance(60_001); + expect(f.queue.resend("p1", item.message.id, "fingerprint", async () => ({ ok: false }))).toBe(first); + }); it("deduplicates the same connection's request and rejects id reuse for another message", () => { const f = fixture(); const first = f.enqueue(1); diff --git a/server/pending-input.ts b/server/pending-input.ts index d3506f70e..640b7b6c3 100644 --- a/server/pending-input.ts +++ b/server/pending-input.ts @@ -1,6 +1,24 @@ import type { AgentStatus, PendingMessage } from "../shared/protocol.ts"; -export interface PendingIdentity { agent: string | null; terminalId: string; session: string | null } +export interface PendingIdentity { + agent: string | null; + terminalId: string; + session: string | null; + processPid?: number | null; + processBirth?: number | null; + omoSession?: string | null; +} + +/** An identity learned after enqueue is not a replacement; only mutually known parts disagree. */ +export function samePendingIdentity(before: PendingIdentity, after: PendingIdentity, platform: NodeJS.Platform = process.platform): boolean { + const changed = (a: T | null | undefined, b: T | null | undefined) => a != null && b != null && a !== b; + return before.agent === after.agent && before.terminalId === after.terminalId + && !changed(before.session, after.session) && !changed(before.omoSession, after.omoSession) + && !changed(before.processPid, after.processPid) + // macOS derives birth from whole elapsed seconds; reads of one PID drift by up to a second. + && (before.processBirth == null || after.processBirth == null + || (platform === "darwin" ? Math.abs(before.processBirth - after.processBirth) <= 1000 : before.processBirth === after.processBirth)); +} export interface PendingRecord { owner: Owner; paneId: string; @@ -10,6 +28,7 @@ export interface PendingRecord { inFlight: boolean; } type Fault = { code: string; message: string }; +interface HeldCopyReply { ok: boolean; code?: string; message?: string } type Removed = { id: string; outcome: "sent" | "discarded" }; /** `by`: the message whose send opened the gate */ interface TurnGate { by: string; committed: boolean; sawWorking: boolean; completed: boolean; deadline: number | null; restart?: "interruption" | "working" } @@ -33,6 +52,7 @@ export class PendingInputs { private readonly gates = new Map(); private readonly closed = new Map(); private readonly removed = new Map(); + private readonly resends = new Map; settledAt: number | null; sent: boolean }>(); constructor( private readonly changed: (owner: Owner, paneId: string, messages: PendingMessage[], removed?: Removed[]) => void, @@ -41,6 +61,28 @@ export class PendingInputs { private readonly newId: () => string = () => crypto.randomUUID(), ) {} + /** Cross-connection held copies share one outcome; never evict an in-flight send. */ + resend(paneId: string, id: string, fingerprint: string, task: () => Promise, generation = 0): Promise { + const key = JSON.stringify([paneId, id, generation]); + this.expireResends(); + const previous = this.resends.get(key); + if (previous) { + if (previous.fingerprint !== fingerprint) throw new PendingInputError("invalid_submit_id", "This resend id already belongs to another message"); + return previous.reply; + } + if (this.resends.size >= MAX_PENDING_TOTAL) throw new PendingInputError("pending_limit", "Too many held-copy sends are still retained; try again later"); + const entry: { id: string; fingerprint: string; settledAt: number | null; sent: boolean; reply: Promise } = { id, fingerprint, settledAt: null, sent: false, reply: Promise.resolve().then(task) }; + this.resends.set(key, entry); + entry.reply.then((reply) => { entry.sent = reply.ok; entry.settledAt = this.now(); }, () => { entry.settledAt = this.now(); }); + return entry.reply; + } + + private expireResends(): void { + for (const [key, entry] of this.resends) { + if (entry.settledAt !== null && !(entry.sent && this.records.has(entry.id)) && this.now() - entry.settledAt >= CLOSED_RETENTION_MS) this.resends.delete(key); + } + } + list(owner: Owner, paneId: string): PendingMessage[] { return [...this.records.values()].filter((item) => item.owner === owner && item.paneId === paneId).map((item) => ({ ...item.message, ...(item.message.error ? { error: { ...item.message.error } } : {}) })); } @@ -119,7 +161,8 @@ export class PendingInputs { this.records.delete(item.message.id); this.remember(item, "sent"); this.publish(item.owner, item.paneId, [{ id: item.message.id, outcome: "sent" }]); - if (this.gates.get(item.paneId)?.completed) this.gates.delete(item.paneId); + const gate = this.gates.get(item.paneId); + if (gate?.completed || (gate?.by === item.message.id && !gate.committed)) this.gates.delete(item.paneId); return; } item.message.state = uncertain ? "uncertain" : retryWhenReady ? "queued" : "held"; @@ -203,6 +246,7 @@ export class PendingInputs { close(owner: Owner): void { this.hold(owner); this.closed.set(owner, this.now()); } expire(): void { + this.expireResends(); for (const [paneId, gate] of this.gates) { if (gate.deadline !== null && this.now() >= gate.deadline && ![...this.records.values()].some((item) => item.paneId === paneId && item.inFlight)) { this.holdPane(paneId, { code: "pending_turn_unconfirmed", message: "The next turn did not start in time; review and send the remaining messages explicitly" }); diff --git a/server/pi-large-history.test.ts b/server/pi-large-history.test.ts new file mode 100644 index 000000000..b600616a5 --- /dev/null +++ b/server/pi-large-history.test.ts @@ -0,0 +1,104 @@ +import { afterAll, expect, test } from "bun:test"; +import { appendFileSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { forgetAllPiIndexes, piBranchSegments, piEntryIndex, piIndexCacheUsage, PI_INDEX_CACHE_BYTES, MAX_PI_ENTRY_BYTES, MAX_PI_INDEX_ENTRIES } from "./pi-tree.ts"; +import { transcriptPage, transcriptToolOutput, piTranscriptImage, piPageCacheUsage, forgetTranscriptState } from "./conversation.ts"; +import { withinRetentionBudget } from "./retention-budget.ts"; +const dir = mkdtempSync(join(tmpdir(), "pi-bounded-history-")); +// the readers keep module-wide caches keyed by path: leave none behind for the files removed here +afterAll(() => { forgetTranscriptState(); forgetAllPiIndexes(); rmSync(dir, { recursive: true, force: true }); }); +const textOf = (page: ReturnType) => page.turns.flatMap(t => t.parts).flatMap(p => p.kind === "text" ? [p.text] : []); +const message = (id: string, parentId: string | null, role: string, text: string) => ({ id, parentId, type: "message", message: { role, content: [{ type: "text", text }] } }); +test("history beyond 64MiB remains paged and complete, stable on append, with bounded index retention", () => { + const path = join(dir, "large.jsonl"); writeFileSync(path, ""); let parent: string | null = null; + const add = (record: Record) => { appendFileSync(path, JSON.stringify({ parentId: parent, ...record }) + "\n"); parent = record.id as string; }; + for (let i = 0; i < 140; i++) { + add(message("u" + i, parent, "user", "question " + i)); + add({ id: "pad" + i, type: "custom", data: "x".repeat(512 * 1024) }); + add(message("a" + i, parent, "assistant", "answer " + i)); + } + expect(statSync(path).size).toBeGreaterThan(64 * 1024 * 1024); + expect(piBranchSegments(path, statSync(path).size)).not.toBeNull(); + let page = transcriptPage("pi-transcript", path); const history = page.history_id; + expect(textOf(page)).toContain("answer 139"); expect(page.cursor).not.toBeNull(); expect(page.turns.length).toBeLessThan(80); + const all = new Set(); let pages = 0; + for (;;) { + textOf(page).forEach(t => { expect(all.has(t)).toBe(false); all.add(t); }); + if (!page.cursor) break; + expect(++pages).toBeLessThan(100); page = transcriptPage("pi-transcript", path, { before: page.cursor }); + } + expect(all.size).toBe(280); expect(all.has("question 0")).toBe(true); + add(message("u-next", parent, "user", "appended")); + const newest = transcriptPage("pi-transcript", path); expect(newest.history_id).toBe(history); expect(textOf(newest)).toContain("appended"); + forgetTranscriptState(); expect(textOf(transcriptPage("pi-transcript", path))).toEqual(textOf(newest)); + add({ id: "tool", type: "message", message: { role: "toolResult", toolCallId: "fixture-call", toolName: "read", content: [{ type: "text", text: "kept output" }, { type: "image", mimeType: "image/png", data: Buffer.from("fixture-image").toString("base64") }] } }); + expect(transcriptToolOutput("pi-transcript", path, "fixture-call")).toContain("kept output"); + expect(Buffer.from(piTranscriptImage(path, "pi:fixture-call:0")!.bytes).toString()).toBe("fixture-image"); + // Abandoned records remain inaccessible, even with large preceding history. + add(message("other-branch", "u0", "assistant", "new branch")); + expect(transcriptToolOutput("pi-transcript", path, "fixture-call")).toBeNull(); + expect(piTranscriptImage(path, "pi:fixture-call:0")).toBeNull(); + expect(textOf(transcriptPage("pi-transcript", path))).toEqual(["question 0", "new branch"]); + expect(piIndexCacheUsage().estimatedBytes).toBeLessThanOrEqual(PI_INDEX_CACHE_BYTES); +}, 30000); +test("index discards unused compaction summaries and evicts cached files", () => { + for (let i = 0; i < 7; i++) { + const path = join(dir, `cache-${i}.jsonl`); + writeFileSync(path, JSON.stringify({ id: "c", parentId: null, type: "compaction", summary: "x".repeat(1024 * 1024) }) + "\n"); + const index = piEntryIndex(path)!; expect(index.entries[0]!.summary).toBeNull(); expect(index.retainedBytes).toBeLessThan(1000); + } + expect(piIndexCacheUsage().files).toBeLessThanOrEqual(4); + expect(piIndexCacheUsage().estimatedBytes).toBeLessThanOrEqual(PI_INDEX_CACHE_BYTES); +}); +test("oversized individual records and excessive entry counts fail closed without a partial cached graph", () => { + const path = join(dir, "oversized.jsonl"); + writeFileSync(path, JSON.stringify({ id: "large", type: "custom", data: "x".repeat(MAX_PI_ENTRY_BYTES) }) + "\n"); + expect(piEntryIndex(path)).toBeNull(); expect(piBranchSegments(path, statSync(path).size)).toBeNull(); + writeFileSync(path, JSON.stringify(message("good", null, "user", "restored")) + "\n"); expect(piEntryIndex(path)!.entries).toHaveLength(1); + const many = join(dir, "many.jsonl"); writeFileSync(many, Array.from({ length: MAX_PI_INDEX_ENTRIES + 1 }, (_, i) => JSON.stringify({ id: String(i), parentId: i ? String(i - 1) : null })).join("\n") + "\n"); + expect(piEntryIndex(many)).toBeNull(); + writeFileSync(path, JSON.stringify({ id: "bad", type: "x".repeat(65) }) + "\n"); expect(piEntryIndex(path)).toBeNull(); + const summary = "s".repeat(9 * 1024 * 1024); + writeFileSync(path, JSON.stringify({ id: "one", type: "branch_summary", parentId: null, summary }) + "\n"); + expect(piEntryIndex(path)).not.toBeNull(); // valid: large, and still within the metadata budget + appendFileSync(path, JSON.stringify({ id: "two", type: "branch_summary", parentId: "one", summary }) + "\n"); + expect(piEntryIndex(path)).toBeNull(); // retained metadata budget, independent of history byte size +}, 15000); +test("a valid index above 8 MiB of metadata is cached: an unchanged history is not indexed again, an append extends it", () => { + forgetAllPiIndexes(); + const path = join(dir, "many-small.jsonl"); + writeFileSync(path, Array.from({ length: 40_000 }, (_, i) => JSON.stringify(message("m" + i, i ? "m" + (i - 1) : null, "user", "q" + i))).join("\n") + "\n"); + const first = piEntryIndex(path)!; + expect(first.retainedBytes).toBeGreaterThan(8 * 1024 * 1024); + expect(piEntryIndex(path)).toBe(first); // the same index object: nothing was rebuilt + appendFileSync(path, JSON.stringify(message("next", "m39999", "user", "appended")) + "\n"); + const extended = piEntryIndex(path)!; + expect(extended).toBe(first); // extended in place from the scanned offset + expect(extended.entries).toHaveLength(40_001); + expect(piIndexCacheUsage().estimatedBytes).toBeLessThanOrEqual(PI_INDEX_CACHE_BYTES); + // other files read after it push it out once the budget is spent, never the one just read + for (let i = 0; i < 4; i++) { const other = join(dir, `after-${i}.jsonl`); writeFileSync(other, JSON.stringify(message("u", null, "user", "x")) + "\n"); piEntryIndex(other); } + expect(piIndexCacheUsage().files).toBe(4); + expect(piEntryIndex(path)).not.toBe(first); // evicted as least recently read, then rebuilt +}, 30000); +test("parsed page caches admit at most four small pages each and still serve oversized pages uncached", () => { + forgetTranscriptState(); + for (let i = 0; i < 7; i++) { + const path = join(dir, `page-cache-${i}.jsonl`); + writeFileSync(path, JSON.stringify(message("u", null, "user", "hello")) + "\n"); + transcriptPage("pi-transcript", path); + } + expect(piPageCacheUsage()).toEqual({ pages: 4, settled: 4, withinBudget: true }); + forgetTranscriptState(); + const path = join(dir, "large-page.jsonl"), body = "y".repeat(3 * 1024 * 1024); + writeFileSync(path, [message("u", null, "user", body), message("a", "u", "assistant", "ok"), message("u2", "a", "user", "next")].map(r => JSON.stringify(r)).join("\n") + "\n"); + expect(textOf(transcriptPage("pi-transcript", path))).toContain(body); + expect(piPageCacheUsage()).toEqual({ pages: 0, settled: 0, withinBudget: true }); +}); +test("retention estimator bounds large strings, nested structures and Maps without following cycles forever", () => { + expect(withinRetentionBudget({ text: "x".repeat(300) }, 100)).toBe(false); + expect(withinRetentionBudget({ text: "small" }, 1000)).toBe(true); + expect(withinRetentionBudget(new Map([["title", "x".repeat(300)]]), 100)).toBe(false); + const cycle: any = {}; cycle.self = cycle; expect(withinRetentionBudget(cycle, 1000)).toBe(true); +}); diff --git a/server/pi-tree.ts b/server/pi-tree.ts index 8ba425e9e..e0257d6e3 100644 --- a/server/pi-tree.ts +++ b/server/pi-tree.ts @@ -9,15 +9,17 @@ */ import { closeSync, openSync, readSync, statSync } from "node:fs"; +/** Bound work/retention, not cumulative history bytes: pages already bound transcript reads. */ +export const MAX_PI_INDEX_ENTRIES = 100_000; +export const MAX_PI_ENTRY_BYTES = 16 * 1024 * 1024; +const MAX_PI_INDEX_METADATA_BYTES = 32 * 1024 * 1024; /** - * A branch longer than this is not returned at all. The caller treats that as a transcript it - * cannot read (`branch_unreadable`) and the chat falls back to the pane's terminal output, so an - * oversized session shows scrollback rather than a projection built by holding that many bytes of - * paths in memory. The largest branch across 147 real session files here is 23.3 MiB (24,451,068 - * bytes), so the cap sits 2.7x above what pi has been seen to write — close enough that a long-lived - * pane could reach it, which is why it degrades to the fallback instead of throwing. + * The index cache keeps any index the builder accepts, so an unchanged history is never indexed + * twice whatever its size, and holds at most this much estimated metadata across at most + * PI_INDEX_CACHE_FILES files: the least recently read goes first, never the one just read. */ -export const MAX_BRANCH_BYTES = 64 * 1024 * 1024; +export const PI_INDEX_CACHE_BYTES = MAX_PI_INDEX_METADATA_BYTES; +const PI_INDEX_CACHE_FILES = 4; /** One range of the transcript file, in the order it should be read. */ export interface TranscriptSegment { start: number; end: number } @@ -26,6 +28,7 @@ interface PiEntry { id: string; parent: string | null; start: number; end: numbe interface PiIndex { /** the file the index was built from (device and inode): another file at the same path starts over */ file: string; + retainedBytes: number; /** the tree's children in append order; entries without an id (the session header) are not nodes */ entries: PiEntry[]; children: Map; @@ -46,7 +49,9 @@ function indexEntry(line: string, start: number, end: number): PiEntry | null { // type and role are taken here because the line is parsed for this anyway: what counts as a // turn is decided later by a reader that must not pay for a second pass over the file const message = entry.message; + if (entry.id.length > 256 || (typeof entry.parentId === "string" && entry.parentId.length > 256)) throw new Error("Pi entry identity exceeds limit"); const role = message !== null && typeof message === "object" && typeof message.role === "string" ? message.role : ""; + if ((typeof entry.type === "string" && entry.type.length > 64) || role.length > 64) throw new Error("Pi entry metadata exceeds limit"); return { id: entry.id, parent: typeof entry.parentId === "string" && entry.parentId.length > 0 ? entry.parentId : null, @@ -54,12 +59,12 @@ function indexEntry(line: string, start: number, end: number): PiEntry | null { end, type: typeof entry.type === "string" ? entry.type : "", role, - summary: typeof entry.summary === "string" ? entry.summary : null, + summary: entry.type === "branch_summary" && typeof entry.summary === "string" ? entry.summary : null, }; } function buildIndex(path: string, size: number, file: string): PiIndex { - const index: PiIndex = { file, entries: [], children: new Map(), scanned: 0, tail: "" }; + const index: PiIndex = { file, retainedBytes: 0, entries: [], children: new Map(), scanned: 0, tail: "" }; extendIndex(index, path, size); return index; } @@ -87,6 +92,7 @@ function extendIndex(index: PiIndex, path: string, size: number): void { const read = chunk.subarray(0, got); if (!read.includes(0x0a)) { open.push(read); + if (carry.length + open.reduce((sum, part) => sum + part.length, 0) > MAX_PI_ENTRY_BYTES) throw new Error("Pi record exceeds read limit"); end += got; continue; } @@ -96,11 +102,13 @@ function extendIndex(index: PiIndex, path: string, size: number): void { const base = end - held - carry.length; // absolute offset of bytes[0] let offset = 0; for (let newline = bytes.indexOf(0x0a); newline !== -1; newline = bytes.indexOf(0x0a, offset)) { + if (newline - offset > MAX_PI_ENTRY_BYTES) throw new Error("Pi record exceeds read limit"); const entry = indexEntry(bytes.subarray(offset, newline).toString("utf8"), base + offset, base + newline + 1); if (entry) addEntry(index, entry); offset = newline + 1; } carry = bytes.subarray(offset); + if (carry.length > MAX_PI_ENTRY_BYTES) throw new Error("Pi record exceeds read limit"); end += got; } index.scanned = end - carry.length - open.reduce((sum, part) => sum + part.length, 0); @@ -119,6 +127,9 @@ function readBefore(path: string, offset: number, bytes: number): string { } function addEntry(index: PiIndex, entry: PiEntry): void { + if (index.entries.length >= MAX_PI_INDEX_ENTRIES) throw new Error("Pi index entry limit reached"); + index.retainedBytes += 192 + 2 * (entry.id.length + (entry.parent?.length ?? 0) + entry.type.length + entry.role.length + (entry.summary?.length ?? 0)); + if (index.retainedBytes > MAX_PI_INDEX_METADATA_BYTES) throw new Error("Pi index metadata budget reached"); index.entries.push(entry); if (entry.parent === null) return; const siblings = index.children.get(entry.parent); @@ -136,14 +147,22 @@ export function piEntryIndex(path: string): PiIndex | null { // a truncation or an in-place replacement can, and only the tail check tells them apart; // a file put in the old one's place (an import, a restore) is another file even where its // tail reads the same - if (cached && cached.file === file && cached.scanned <= size && readBefore(path, cached.scanned, 64) === cached.tail) { - extendIndex(cached, path, size); - return cached; - } - const fresh = buildIndex(path, size, file); - piIndexes.set(path, fresh); - if (piIndexes.size > 32) piIndexes.delete(piIndexes.keys().next().value!); - return fresh; + piIndexes.delete(path); // a failed extension must not leave a partially indexed graph cached + try { + const reusable = cached && cached.file === file && cached.scanned <= size && readBefore(path, cached.scanned, 64) === cached.tail; + const fresh = reusable ? cached : buildIndex(path, size, file); + if (reusable) extendIndex(fresh, path, size); + piIndexes.set(path, fresh); // last in the map's order: the most recently read + let total = 0; + for (const item of piIndexes.values()) total += item.retainedBytes; + for (const [other, item] of piIndexes) { + if (piIndexes.size <= PI_INDEX_CACHE_FILES && total <= PI_INDEX_CACHE_BYTES) break; + if (other === path) break; // the builder never accepts more than the budget, so this one fits alone + piIndexes.delete(other); + total -= item.retainedBytes; + } + return fresh; + } catch { return null; } } /** Drops one file's index: the next read rebuilds it from the file, which is all it ever was. */ @@ -160,9 +179,8 @@ export function forgetAllPiIndexes(): void { * The active branch: the last entry written back to its root, oldest first, as byte * ranges of the file. Adjacent entries merge into one range, so a session no /tree * touched reads as the single prefix it is. An id repeated (never pi, but never - * trusted) keeps its last append; a branch over MAX_BRANCH_BYTES is refused, and the chat falls - * back to terminal output - * of projected. Null when the tree cannot be read, or holds no entry at all. + * trusted) keeps its last append. Only offsets are projected, never a branch-sized buffer. + * Index entry/record budgets still fail closed rather than invent a partial branch. Null when the tree cannot be read, or holds no entry at all. */ export function piBranchSegments(path: string, size: number): TranscriptSegment[] | null { const index = piEntryIndex(path); @@ -175,12 +193,9 @@ export function piBranchSegments(path: string, size: number): TranscriptSegment[ const chain: PiEntry[] = []; const seen = new Set(); let next: PiEntry | undefined = leaf; - let total = 0; while (next !== undefined && !seen.has(next.id)) { seen.add(next.id); chain.push(next); - total += next.end - next.start; - if (total > MAX_BRANCH_BYTES) return null; const parent: PiEntry | undefined = next.parent === null ? undefined : byId.get(next.parent); // a parent that never appears (a foreign id, a torn index) ends the walk short: // what it still resolves to is shown, the unreadable head is not invented @@ -195,6 +210,34 @@ export function piBranchSegments(path: string, size: number): TranscriptSegment[ return segments; } +/** Read auxiliary tool/image records one at a time, never allocating a merged branch range. */ +export function* piBranchRecords(path: string, size: number): Generator { + const branch = piBranchSegments(path, size), index = piEntryIndex(path); + if (!branch || !index || (index.entries.at(-1)?.end ?? 0) > size) return; + let fd: number; + try { fd = openSync(path, "r"); } catch { return; } // moved or removed since it was indexed: nothing to read + try { + for (const segment of branch) { + let low = 0, high = index.entries.length; + while (low < high) { const mid = (low + high) >>> 1; if (index.entries[mid]!.start < segment.start) low = mid + 1; else high = mid; } + for (let at = low; at < index.entries.length; at++) { + const entry = index.entries[at]!; + if (entry.end > segment.end) break; + const length = entry.end - entry.start; + if (length > MAX_PI_ENTRY_BYTES + 1) return; + const bytes = Buffer.alloc(length); + if (readSync(fd, bytes, 0, length, entry.start) !== length) return; + yield bytes.toString("utf8"); + } + } + } finally { closeSync(fd); } +} + +/** Test/diagnostic summary: metadata estimates only; never session contents. */ +export function piIndexCacheUsage(): { files: number; estimatedBytes: number } { + return { files: piIndexes.size, estimatedBytes: [...piIndexes.values()].reduce((sum, item) => sum + item.retainedBytes, 0) }; +} + /** * What the file holds that the chat cannot show: the turns on paths pi walked away from. * diff --git a/server/pi.test.ts b/server/pi.test.ts index f6b550e2f..9db98d6a7 100644 --- a/server/pi.test.ts +++ b/server/pi.test.ts @@ -8,9 +8,14 @@ import type { ConversationTurn } from "../shared/protocol.ts"; const root = mkdtempSync(join(tmpdir(), "herdr-pi-store-")); const store = join(root, "sessions"); -const slug = join(store, `--${root.replaceAll("/", "-")}--`); +const slug = join(store, `--${root.replace(/[\\/:]/g, "-")}--`); mkdirSync(slug, { recursive: true }); afterAll(() => rmSync(root, { recursive: true, force: true })); +const canSymlink = (() => { + const probe = join(root, "symlink-capability"); + try { symlinkSync(store, probe, "dir"); rmSync(probe); return true; } + catch (error) { if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") return false; throw error; } +})(); const session = (name: string, body: unknown[] = []) => { const path = join(slug, `${name}.jsonl`); @@ -36,12 +41,14 @@ describe("pi's session store holds the transcript a pane reads", () => { expect(piTranscriptInStore(join(root, "missing.jsonl"), store)).toBeNull(); }); - it("refuses a link out of the store, a directory and a file that is not jsonl", () => { + it.skipIf(!canSymlink)("refuses a link out of the store", () => { const outside = join(root, "target.jsonl"); writeFileSync(outside, "{}\n"); const link = join(slug, "link.jsonl"); symlinkSync(outside, link); expect(piTranscriptInStore(link, store)).toBeNull(); + }); + it("refuses a directory and a file that is not jsonl", () => { mkdirSync(join(slug, "dir.jsonl"), { recursive: true }); expect(piTranscriptInStore(join(slug, "dir.jsonl"), store)).toBeNull(); const other = join(slug, "notes.txt"); @@ -58,21 +65,20 @@ describe("pi's session store holds the transcript a pane reads", () => { expect(unwrittenSession(join(slug, "fresh.txt"), store)).toBeNull(); expect(unwrittenSession("fresh.jsonl", store)).toBeNull(); expect(unwrittenSession(join(root, "no-store", "fresh.jsonl"), join(root, "no-store"))).toBeNull(); + }); + it.skipIf(!canSymlink)("refuses a dangling session link", () => { const dangling = join(slug, "dangling.jsonl"); symlinkSync(join(root, "nowhere.jsonl"), dangling); expect(unwrittenSession(dangling, store)).toBeNull(); }); - it("resolves the directories around an unwritten session before trusting its place", () => { + it.skipIf(!canSymlink)("resolves the directories around an unwritten session before trusting its place", () => { // a link in the store to a directory outside it does not make its children the store's mkdirSync(join(root, "outside-dir"), { recursive: true }); symlinkSync(join(root, "outside-dir"), join(store, "escape")); expect(unwrittenSession(join(store, "escape", "fresh.jsonl"), store)).toBeNull(); symlinkSync(join(root, "nowhere-dir"), join(store, "broken")); expect(unwrittenSession(join(store, "broken", "fresh.jsonl"), store)).toBeNull(); - // a file where a directory should be holds no session - writeFileSync(join(slug, "plain"), ""); - expect(unwrittenSession(join(slug, "plain", "fresh.jsonl"), store)).toBeNull(); // the store reached through a link is still the store const alias = join(root, "store-alias"); symlinkSync(store, alias); @@ -80,6 +86,11 @@ describe("pi's session store holds the transcript a pane reads", () => { expect(unwrittenSession(join(alias, "fresh.jsonl"), store)).toEqual({ id: "fresh", path: join(realpathSync(store), "fresh.jsonl") }); }); + it("a file where a directory should be holds no unwritten session", () => { + writeFileSync(join(slug, "plain"), ""); + expect(unwrittenSession(join(slug, "plain", "fresh.jsonl"), store)).toBeNull(); + }); + it("follows PI_CODING_AGENT_SESSION_DIR the way Codex follows CODEX_HOME", () => { const previous = process.env["PI_CODING_AGENT_SESSION_DIR"]; process.env["PI_CODING_AGENT_SESSION_DIR"] = store; diff --git a/server/pi.ts b/server/pi.ts index 1eeacf18d..cafffbf0f 100644 --- a/server/pi.ts +++ b/server/pi.ts @@ -1,7 +1,7 @@ import { constants, closeSync, fstatSync, lstatSync, openSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; -import { herdrRpc } from "./herdr/client.ts"; +import { agentGet } from "./herdr/client.ts"; import { piAgentDir } from "./pi-models.ts"; /** @@ -76,11 +76,8 @@ export function unwrittenSession(path: string, sessionDir: string): { id: string * `/clone` need no inference here: the next read names the file that replaced the old one. */ export async function piTranscriptPath(paneId: string, sessionDir = defaultPiSessionDir()): Promise<{ path: string } | { unwritten: { id: string; path: string } } | null> { - const info = await herdrRpc<{ agent: { agent_session?: { agent?: unknown; kind?: unknown; value?: unknown } } }>( - "agent.get", - { target: paneId }, - ).catch(() => null); - const session = info?.agent?.agent_session; + const agent = await agentGet(paneId).catch(() => null); + const session = agent?.agent_session; // pi reports an absolute path; the id-only form belongs to agents with no store of ours. if (session?.kind !== "path" || typeof session.value !== "string") return null; const path = piTranscriptInStore(session.value, sessionDir); diff --git a/server/ping.contract.test.ts b/server/ping.contract.test.ts new file mode 100644 index 000000000..fcc763f09 --- /dev/null +++ b/server/ping.contract.test.ts @@ -0,0 +1,106 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ServerMessage } from "../shared/protocol.ts"; +import { MachineRelay } from "./machine-relay.ts"; +import type { MachineManager } from "./machines.ts"; +import { createServer, type ServerInstance } from "./index.ts"; + +/** A socket whose frames are awaited as events, each wait bounded by a deadline. */ +class FrameSocket { + readonly seen: ServerMessage[] = []; + private readonly waiters = new Set<() => void>(); + readonly opened: Promise; + + constructor(readonly ws: WebSocket) { + ws.addEventListener("message", (event) => { + this.seen.push(JSON.parse(String(event.data)) as ServerMessage); + for (const waiter of this.waiters) waiter(); + }); + this.opened = new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }); + ws.addEventListener("error", () => reject(new Error("WebSocket failed")), { once: true }); + }); + } + + next(predicate: (message: ServerMessage) => boolean, label: string, from = 0): Promise { + return new Promise((resolve, reject) => { + const check = () => { + const found = this.seen.slice(from).find(predicate); + if (!found) return; + this.waiters.delete(check); clearTimeout(timer); resolve(found); + }; + const timer = setTimeout(() => { this.waiters.delete(check); reject(new Error(`${label} not received within 10s`)); }, 10_000); + this.waiters.add(check); + check(); + }); + } + + send(message: unknown): void { this.ws.send(JSON.stringify(message)); } +} + +describe("WebSocket liveness ping", () => { + const stateDir = mkdtempSync(join(tmpdir(), "herdr-web-ui-ping-")); + const sockets: WebSocket[] = []; + const relays: MachineRelay[] = []; + let bridge: ServerInstance | undefined; + let proxy: ReturnType> | undefined; + + async function connect(url: string): Promise { + const socket = new FrameSocket(new WebSocket(url)); + sockets.push(socket.ws); + await socket.opened; + await socket.next((message) => message.type === "snapshot", "snapshot"); + return socket; + } + + beforeAll(() => { + bridge = createServer({ port: 0, hostname: "127.0.0.1", stateDir, token: "test-ping", machines: false, tailscaleOwner: null }); + // a remote PC's socket, as /ws?machine_id= relays it, with this bridge as the remote + const manager = { endpoint: () => ({ url: `http://127.0.0.1:${bridge!.port}`, token: "test-ping" }), trackTerminal: () => () => {} } as unknown as MachineManager; + proxy = Bun.serve<{ relay: MachineRelay }>({ port: 0, hostname: "127.0.0.1", async fetch(request, instance) { + const relay = new MachineRelay(manager, "remote"); relays.push(relay); + await relay.ready; + if (instance.upgrade(request, { data: { relay } })) return; + relay.close(); return new Response(null, { status: 426 }); + }, websocket: { open(ws) { ws.data.relay.bind(ws); }, message(ws, raw) { ws.data.relay.message(raw); }, close(ws) { ws.data.relay.close(); } } }); + }); + + afterAll(() => { + for (const socket of sockets) socket.close(); + for (const relay of relays) relay.close(); + proxy?.stop(true); + bridge?.stop(); + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("lists ping in the snapshot and answers each ping with a pong of its id, for any role", async () => { + const RuntimeSocket = WebSocket as unknown as new (url: string, options: { headers: Record }) => WebSocket; + const socket = new FrameSocket(new RuntimeSocket(`ws://127.0.0.1:${bridge!.port}/ws`, { headers: { authorization: "Bearer test-ping" } })); + sockets.push(socket.ws); + await socket.opened; + const snapshot = await socket.next((message) => message.type === "snapshot", "snapshot"); + expect(snapshot.type === "snapshot" && snapshot.features).toContain("ping"); + socket.send({ type: "ping", id: 7 }); + expect(await socket.next((message) => message.type === "pong", "pong")).toEqual({ type: "pong", id: 7 }); + socket.send({ type: "role", mode: "observe" }); + await socket.next((message) => message.type === "role-ack" && message.mode === "observe", "observe role"); + const from = socket.seen.length; + socket.send({ type: "ping", id: 8 }); + expect(await socket.next((message) => message.type === "pong", "observer pong", from)).toEqual({ type: "pong", id: 8 }); + // a malformed id is answered in-band, never with a pong + const before = socket.seen.length; + socket.send({ type: "ping", id: "x" }); + expect(await socket.next((message) => message.type === "error", "invalid ping", before)).toMatchObject({ code: "invalid_ping" }); + expect(socket.seen.slice(before).some((message) => message.type === "pong")).toBe(false); + }, 20_000); + + it("relays a ping to a remote PC and its pong back unchanged", async () => { + const socket = await connect(`ws://127.0.0.1:${proxy!.port}/ws`); + const snapshot = socket.seen.find((message) => message.type === "snapshot"); + expect(snapshot?.type === "snapshot" && snapshot.features).toContain("ping"); + socket.send({ type: "ping", id: 42 }); + expect(await socket.next((message) => message.type === "pong", "relayed pong")).toEqual({ type: "pong", id: 42 }); + }, 20_000); +}); diff --git a/server/preferences.test.ts b/server/preferences.test.ts index 1a56c3e28..26ffea2c6 100644 --- a/server/preferences.test.ts +++ b/server/preferences.test.ts @@ -25,12 +25,12 @@ describe("shared preferences", () => { expect(first.status).toBe(200); expect(await first.json()).toEqual({ revision: 2, settings: { showThinking: false, theme: "light" }, pins: ["local:w1:p1", "pc:w2:p3"] }); // a change lays its settings over what is held, and leaves the pins when it names none - await put(store, JSON.stringify({ settings: { agentPins: true } })); - expect(store.get()).toEqual({ revision: 3, settings: { showThinking: false, theme: "light", agentPins: true }, pins: ["local:w1:p1", "pc:w2:p3"] }); + await put(store, JSON.stringify({ settings: { agentOrder: "activity" } })); + expect(store.get()).toEqual({ revision: 3, settings: { showThinking: false, theme: "light", agentOrder: "activity" }, pins: ["local:w1:p1", "pc:w2:p3"] }); await put(store, JSON.stringify({ settings: { theme: "dark" }, pins: [] })); expect(heard).toEqual([1, 2, 3, 4]); - expect(JSON.parse(readFileSync(join(dir, "preferences.json"), "utf8"))).toMatchObject({ revision: 4, settings: { showThinking: false, theme: "dark", agentPins: true }, pins: [] }); - expect(new PreferenceStore(dir).get()).toEqual({ revision: 4, settings: { showThinking: false, theme: "dark", agentPins: true }, pins: [] }); + expect(JSON.parse(readFileSync(join(dir, "preferences.json"), "utf8"))).toMatchObject({ revision: 4, settings: { showThinking: false, theme: "dark", agentOrder: "activity" }, pins: [] }); + expect(new PreferenceStore(dir).get()).toEqual({ revision: 4, settings: { showThinking: false, theme: "dark", agentOrder: "activity" }, pins: [] }); }); it("refuses what is not a preferences document", async () => { diff --git a/server/prompt.test.ts b/server/prompt.test.ts index 792eb2fda..dba1d1108 100644 --- a/server/prompt.test.ts +++ b/server/prompt.test.ts @@ -1,13 +1,17 @@ import { describe, expect, test } from "bun:test"; -import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { InteractivePrompt } from "../shared/protocol.ts"; -import { answerKeys, claudeClearableDraft, claudeInputDraft, viewportShowsLive, removedInvisible, noteSubmitted, codexQuestionsCollapsed, codexQueuedPrompt, handlePromptRequest, modelListWaits, openOmoAsks, parseClaudeSuggestion, parseFallbackPrompt, parseInteractivePrompt, pendingOmoAsk, promptWaitEnded, claudeRestoredDraft, lastChatSubmitted } from "./prompt.ts"; +import { answerKeys, claudeClearableDraft, claudeInputBoxText, claudeInputDraft, claudeInputEmpty, viewportShowsLive, removedInvisible, noteSubmitted, codexQuestionsCollapsed, codexQueuedPrompt, handlePromptRequest, modelListWaits, openOmoAsks, parseClaudeSuggestion, parseFallbackPrompt, parseInteractivePrompt, pendingOmoAsk, promptWaitEnded, claudeRestoredDraft, lastChatSubmitted } from "./prompt.ts"; const labels = (prompt: InteractivePrompt | null) => prompt?.options.map((option) => option.label); +// Real omo-ai 5.1.30 pane.read captures at 120 and 45 columns, with the recorded open calls. +const omoPendingFixture = (name: string) => readFileSync(new URL(`./fixtures/omo-pending-5.1.30-${name}`, import.meta.url), "utf8"); +const omoExtensionTail = omoPendingFixture("extension-tail.txt"); +const withOmoExtensionTail = (screen: string) => screen.replace(/(❯\n─+\n)/, `$1${omoExtensionTail}`); const CLAUDE_BACKGROUND_APPROVAL_FOOTER = "Esc to cancel · ctrl+x ctrl+k twice to stop background agents"; const claudeBackgroundApproval = (selected: number, footer = CLAUDE_BACKGROUND_APPROVAL_FOOTER) => { const row = (index: number, label: string) => `${index === selected ? " ❯ " : " "}${index + 1}. ${label}`; @@ -1501,6 +1505,66 @@ ${rule60} const accepted = (id: string) => record({ role: "toolResult", toolCallId: id, content: [{ type: "text", text: "Question accepted; the answer will arrive as a user message." }], details: { accepted: true, status: "pending" }, isError: false }); const asyncAsking = `${asking.replace('"waitForAnswer":true', '"waitForAnswer":false')}\n${accepted("call-1")}\n${record({ role: "assistant", content: [{ type: "text", text: "meanwhile" }], stopReason: "stop" })}`; + for (const count of ["one", "two"]) for (const width of ["wide", "narrow"]) { + test(`reads the real 5.1.30 ${count}-question ${width} widget with its pending call`, () => { + const open = openOmoAsks(omoPendingFixture(`${count}.jsonl`)); + const question = open[0]!.questions[0]!; + const prompt = parseInteractivePrompt("pi", omoPendingFixture(`${count}-${width}.txt`), null, false, open); + expect(prompt).toMatchObject({ + agent: "omo", kind: "question", question: question.question, options: question.options, + multi_select: question.multiSelect, custom_option_index: question.multiSelect ? null : question.options.length, + title: count === "one" ? question.header : "Question 1 of 2", + }); + }); + } + + for (const count of ["one", "two"]) for (const width of ["wide", "narrow"]) { + test(`reads the real 5.1.30 ${count}-question ${width} widget with extension status below the box`, () => { + const open = openOmoAsks(omoPendingFixture(`${count}.jsonl`)); + const screen = withOmoExtensionTail(omoPendingFixture(`${count}-${width}.txt`)); + expect(parseInteractivePrompt("pi", screen, null, false, open)).toMatchObject({ + agent: "omo", kind: "question", question: open[0]!.questions[0]!.question, + }); + expect(parseInteractivePrompt("pi", `${screen}$ echo after\n`, null, false, open)).toBeNull(); + }); + } + + test("reads an OmO waiting form with extension status under its rule", () => { + const extended = first.replace(footer, footer.replace(`${rule}\n`, `${rule}\n${omoExtensionTail}`)); + expect(parseInteractivePrompt("pi", extended)).toMatchObject({ agent: "omo", kind: "question", title: "Question 1 of 2" }); + expect(parseInteractivePrompt("pi", `${extended}user@host:~/project$ `)).toBeNull(); + expect(parseInteractivePrompt("pi", extended.replace(omoExtensionTail, `${omoExtensionTail} ordinary output\n`.repeat(4)))).toBeNull(); + }); + + test("reads OmO typing and review forms with extension status under their rule", () => { + const typing = form(` → 표시 위치 월 한도 Submit + 음성 사용량과 추정 비용을 어디에 보여줄까요? + → 1. 설정 > 음성 입력 (추천) + Type your own answer... + Your answer (enter to save, ↑↓ back to options, esc to discard) +> 안녕 + Submit (0/2 answered) — Enter advances + enter save and next ↑↓ back to options tab next question esc discard +`); + const review = form(` 표시 위치 ✓ 월 한도 ✓ → Submit + Review your answers + 표시 위치: 설정 > 음성 입력 (추천) + 월 한도: 한도 없음 + + Comment (optional; unanswered questions are reported) +> + Submit (2/2 answered) + enter submit ↑ review answers shift+tab back tab next question esc back +`); + for (const screen of [typing, review]) { + const expected = parseInteractivePrompt("pi", screen); + expect(expected).not.toBeNull(); + const extended = screen.replace(footer, footer.replace(`${rule}\n`, `${rule}\n${omoExtensionTail}`)); + expect(parseInteractivePrompt("pi", extended)).toEqual(expected); + expect(parseInteractivePrompt("pi", `${extended}$ `)).toBeNull(); + } + }); + test("keeps a call asked without waiting open until it is settled", () => { expect(openOmoAsks(asyncAsking).map((ask) => [ask.questions[0]!.header, ask.wait])).toEqual([["표시 위치", false]]); expect(pendingOmoAsk(asyncAsking)?.questions).toHaveLength(2); @@ -1638,6 +1702,50 @@ describe("Claude's suggested next prompt", () => { expect(draft("❯ loose text\nmore")).toBe(false); }); + test("Claude's tip drawn without colors (NO_COLOR, #926) is no draft; other plain words are", () => { + // Claude Code 2.1.296 under NO_COLOR=1: an empty input box, no SGR color or dim anywhere + const plainRule = "\u2500".repeat(60); + const colorless = (row: string) => ["", plainRule, row, plainRule, " \u23f5\u23f5 auto mode on (shift+tab to cycle) \u00b7 \u2190 for agents"].join("\r\n"); + const strip = (ansi: string) => ansi.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); + const tip = colorless('\u276f Try "how does work?"'); + expect(claudeInputDraft(strip(tip), tip)).toBe(false); + expect(claudeInputEmpty(strip(tip), tip)).toBe(true); + expect(claudeClearableDraft(strip(tip), tip)).toBeNull(); + // Claude's drawn cursor on its first character + const cursor = colorless('\u276f \u001b[7mT\u001b[27mry "how does work?"'); + expect(claudeInputDraft(strip(cursor), cursor)).toBe(false); + // typed words that are not the tip's whole form stay a draft + for (const typed of ['\u276f Try this instead', '\u276f Try "x" and more', '\u276f please Try "x"']) { + expect(claudeInputDraft(strip(colorless(typed)), colorless(typed))).toBe(true); + } + // on a colored screen only dim tells the tip: typed `Try "\u2026"` there is a draft + expect(claudeInputDraft(strip(screen('\u276f Try "x"')), screen('\u276f Try "x"'))).toBe(true); + // colors not verified to show the live screen prove nothing + expect(claudeInputDraft(strip(tip), null)).toBe(true); + }); + + test("OmO's box while it works, under a rule its status labels, is read like Claude's", () => { + // OmO (reported by herdr as Claude) as it draws its box mid-turn: two dashes lead the label + const rule = "\u2500".repeat(60); + const working = (row: string) => ["● earlier output", "\u2500\u2500 • Running eval (7s • esc to interrupt) " + "\u2500".repeat(40), row, rule, + "~/workspace/app • main • 27.1% (auto) claude-opus-5-5:medium"].join("\r\n"); + expect(claudeInputDraft(working("❯"), null)).toBe(false); + expect(claudeInputEmpty(working("❯"), null)).toBe(true); + expect(claudeInputDraft(working("❯ still typing"), null)).toBe(true); + expect(claudeInputBoxText(working("❯ still typing"))).toBe("❯ still typing"); + // a row with one dash before its words is no rule + expect(claudeInputDraft(["─ not a rule ─", "❯", rule].join("\r\n"), null)).toBe(true); + }); + + test("the box a refusal shows: its rows as plain text, nothing when it cannot be read whole", () => { + const rule = "\u2500".repeat(60); + expect(claudeInputBoxText([rule, "\u001b[2m! ls\u001b[0m", rule].join("\r\n"))).toBe("! ls"); + expect(claudeInputBoxText([rule, "❯ first line", " second line", rule].join("\r\n"))).toBe("❯ first line\n second line"); + expect(claudeInputBoxText([" more of a tall draft", rule, " footer"].join("\r\n"))).toBe(""); + expect(claudeInputBoxText("no box here")).toBe(""); + expect(claudeInputBoxText([rule, `❯ ${"x".repeat(300)}`, rule].join("\r\n"))).toBe(`❯ ${"x".repeat(198)}…`); + }); + test("a viewport's colors count only when it is verified to show the live screen", () => { const live = screen("❯ \u001b[2mrun the tests\u001b[0m").replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); const colors = screen("❯ \u001b[2mrun the tests\u001b[0m"); @@ -1836,6 +1944,33 @@ Step 4: Credential source expect(labels(parseFallbackPrompt("claude", `Pick one:\n\n❯ Alpha\n Beta\n${footer}\n`))).toEqual(generic); }); + test("hashes a framed GJC question without the transcript or ticking status above it", () => { + const dialog = "──────────────\n\nUse staging only.\n\nWhich approach?\n\n──────────────\n│❯ First │\n│ Second │\n──────────────\n\nup/down navigate enter select esc cancel\n\n──────────────\n"; + const first = parseFallbackPrompt("gjc", `Earlier output\n◷ 12h23m\n${dialog}`); + const later = parseFallbackPrompt("gjc", `New output\n◷ 12h24m\n${dialog}`); + expect(later.id).toBe(first.id); + expect(first.question).toBe("Which approach?"); + expect(first.body).toBe("Use staging only."); + expect(parseFallbackPrompt("gjc", dialog.replace("Use staging only.", "Use production.")).id).not.toBe(first.id); + expect(parseFallbackPrompt("gjc", dialog.replace("Which approach?", "Delete everything?")).id).not.toBe(first.id); + expect(parseFallbackPrompt("gjc", dialog.replace("Second", "Remove")).id).not.toBe(first.id); + expect(parseFallbackPrompt("gjc", dialog.replace("│❯ First │", "│ First │").replace("│ Second │", "│❯ Second │")).id).toBe(first.id); + }); + + test("keeps approval details above a padded Markdown rule in a framed GJC title", () => { + const dialog = (target: string) => `──────────────\n\n Delete ${target}.\n\n ────────── \n\n Proceed?\n\n❯ Yes\n No\n\nup/down navigate enter select esc cancel\n\n──────────────\n`; + const staging = parseFallbackPrompt("gjc", dialog("staging")); + expect(staging.body).toBe("Delete staging."); + expect(parseFallbackPrompt("gjc", dialog("production")).id).not.toBe(staging.id); + }); + + test("keeps the full GJC screen when a matching outer frame is absent", () => { + for (const close of ["", "────"]) { + const screen = (target: string) => `Delete ${target}.\n──────────────\n\nProceed?\n\n❯ Yes\n No\n\nup/down navigate enter select esc cancel\n\n${close}\n`; + expect(parseFallbackPrompt("gjc", screen("production")).id).not.toBe(parseFallbackPrompt("gjc", screen("staging")).id); + } + }); + test("titles a GJC selector with its own heading, not an earlier question on the screen", () => { const prompt = parseFallbackPrompt("gjc", "Delete your project?\nDone, it is kept.\n\nChoose a provider\n\n❯ Alpha\n Beta\n[↑↓ to navigate, Enter to select, Esc to cancel]\n"); expect(prompt.question).toBe("Choose a provider"); @@ -3003,7 +3138,7 @@ ${omoRule} const path = join(dir, "2026-10-06T00-00-00_qa-session-488.jsonl"); writeFileSync(path, JSON.stringify({ type: "session", id: "qa-session-488", cwd: pane.cwd, timestamp: new Date().toISOString() }) + "\n" + omoCall("q1", options)); // An inert child supplies a real process environment; all RPCs and session files are fixtures. - const child = Bun.spawn([process.execPath, "-e", "process.stdin.resume()"], { stdin: "pipe", stdout: "ignore", stderr: "ignore", env: { ...process.env, OMO_CODING_AGENT_DIR: agentDir } }); + const child = Bun.spawn([process.execPath, "-e", "process.stdin.resume()"], { cwd: pane.cwd, stdin: "pipe", stdout: "ignore", stderr: "ignore", env: { ...process.env, OMO_CODING_AGENT_DIR: agentDir } }); pane.omo = { pid: child.pid, path, live: true }; promptWaitEnded("p_1"); try { await run(pane); } @@ -3011,6 +3146,33 @@ ${omoRule} }); } + for (const count of ["one", "two"]) for (const width of ["wide", "narrow"]) { + test.skipIf(process.platform !== "linux")(`discovers the real 5.1.30 ${count}-question ${width} widget through the prompt route`, async () => { + await withOmo(async (pane) => { + appendFileSync(pane.omo!.path, JSON.stringify({ type: "custom", customType: "ask-user:settlement", data: { requestId: "q1" } }) + "\n" + omoPendingFixture(`${count}.jsonl`)); + pane.screen = omoPendingFixture(`${count}-${width}.txt`); + pane.agent = "pi"; + pane.status = "blocked"; + const question = openOmoAsks(omoPendingFixture(`${count}.jsonl`))[0]!.questions[0]!; + const prompt = await card(); + expect(prompt).toMatchObject({ + agent: "omo", kind: "question", question: question.question, options: question.options, + multi_select: question.multiSelect, custom_option_index: question.multiSelect ? null : question.options.length, + }); + }); + }); + } + + test.skipIf(process.platform !== "linux")("discovers a pending question with extension status below the input box", async () => { + await withOmo(async (pane) => { + appendFileSync(pane.omo!.path, JSON.stringify({ type: "custom", customType: "ask-user:settlement", data: { requestId: "q1" } }) + "\n" + omoPendingFixture("one.jsonl")); + pane.screen = withOmoExtensionTail(omoPendingFixture("one-wide.txt")); + pane.agent = "pi"; + pane.status = "blocked"; + expect(await card()).toMatchObject({ agent: "omo", kind: "question", multi_select: true }); + }); + }); + // Claude Code 2.1.294, live: a message it holds back for an invisible character const heldRule = "─".repeat(80); const heldScreen = (box: string, over = `${" ".repeat(40)}Removed 1 invisible character · review and press Enter to send`) => ` @@ -3241,6 +3403,27 @@ ${heldRule} }); }); + test("answers a framed GJC question across unrelated output and status redraws", async () => { + const screen = (at: number, tick: number) => `Output ${tick}\n◷ 12h${tick}m\n──────────────\n\n${gjcSelector(at)}\n──────────────\n`; + await withPane("gjc", "blocked", screen(0, 23), async (pane) => { + const prompt = (await card())!; + pane.screen = screen(0, 24); + pane.onSent = (sent) => { if (sent === "down") pane.screen = screen(1, 25); }; + expect(await answer(prompt.id, { option_index: 1 })).toEqual({ status: 200, code: undefined }); + expect(pane.sent).toEqual(["down", "enter"]); + }); + }); + + test("refuses a framed GJC answer when its approval details change during navigation", async () => { + const screen = (target: string, at = 0) => `──────────────\n\n Delete ${target}.\n\n ────────── \n\n Proceed?\n\n${at === 0 ? "❯" : " "} Yes\n${at === 1 ? "❯" : " "} No\n\nup/down navigate enter select esc cancel\n\n──────────────\n`; + await withPane("gjc", "blocked", screen("staging"), async (pane) => { + const prompt = (await card())!; + pane.onSent = (sent) => { if (sent === "down") pane.screen = screen("production", 1); }; + expect(await answer(prompt.id, { option_index: 1 })).toEqual({ status: 409, code: "prompt_changed" }); + expect(pane.sent).toEqual(["down"]); + }); + }); + test("checks a GJC selector after each move and stops once it is gone", async () => { const rows = ["First", "Second", "Third", "Fourth"]; await withPane("gjc", "blocked", gjcSelector(0, rows), async (pane) => { diff --git a/server/prompt.ts b/server/prompt.ts index ab378efa5..e18d9e279 100644 --- a/server/prompt.ts +++ b/server/prompt.ts @@ -576,7 +576,7 @@ function parseClaudeQuestion(screen: string): ParsedPrompt | null { // emptied first, after the cursor and before it (ctrl+k, ctrl+u; measured on 2.1.295) ...(drafted ? { customSteps: (text: string) => [ - ...keySteps([...navigationKeys(customIndex - rows.findIndex((row) => row.selected)), "ctrl+k", "ctrl+u"]), + ...keySteps([...navigationKeys(customIndex - rowAt), "ctrl+k", "ctrl+u"]), { text }, ...keySteps([KEY.enter]), ], } : {}), @@ -1875,6 +1875,28 @@ function withoutClaudeTasks(shown: string[], hintEnds: (lines: string[], end: nu return end < shown.length && hintEnds(shown, end) ? shown.slice(0, end) : shown; } +/** The rule below an OmO form or composer, then extension task status and the native footer. */ +function omoFooterIsActive(lines: string[]): boolean { + let rules = 0; + let footer = 0; + let widget = false; + for (const line of lines) { + if (!line) continue; + if (SOLID_RULE_RE.test(line)) { + if (rules > 0 || footer > 0) return false; + rules = 1; + } else if (rules > 0 && footer === 0 && ( + /^(?:[▶◌✗✓] .*\bcategory:|▶ .* \bwave \d+\/\d+\b)/.test(line) + || (widget && /^(?:\d+[hms]\s*)+$/.test(line)) + )) { + // A duration may wrap off the task row. Only these status rows precede the native footer; + // arbitrary output, a second rule, or another program's input still invalidates the form. + widget = true; + } else if (rules === 0 || ++footer > OMO_FOOTER_LINES || OMO_NOT_FOOTER_RE.test(line)) return false; + } + return rules === 1; +} + function promptTailIsActive(prompt: ParsedPrompt, screen: string): boolean { const cleanLines = screen.replace(ANSI_RE, "").split(/\r?\n/).map(cleanLine); const visible = cleanLines.filter((line) => line && !isDivider(line)); @@ -1907,8 +1929,8 @@ function promptTailIsActive(prompt: ParsedPrompt, screen: string): boolean { if (prompt.responder === "claude-effort") return ends(CLAUDE_EFFORT_HINT_RE); if (prompt.responder === "codex-model") return ends(/(?:^|\s)enter select\s*·\s*esc back$|(?:^|\s)enter (?:default|apply)\s*·\s*s session\s*·\s*esc back$/i); if (prompt.responder === "omo-question" || prompt.responder === "omo-review" || prompt.responder === "omo-typing") { - // The form is live only with nothing but OmO's own footer under its hint: blank lines, one - // rule, then the footer's few lines (cwd, context, model). Anything else is the form's text + // The form is live only with OmO's footer and extension status under its hint: blank lines, one + // rule, status widgets, then the footer's few lines (cwd, context, model). Anything else is the form's text // in another program (printed in a shell, quoted in a transcript over an input box), where // an answer's keys would be typed into that program. const hint = { "omo-question": OMO_OPTIONS_HINT_RE, "omo-review": OMO_REVIEW_HINT_RE, "omo-typing": OMO_TYPING_HINT_RE }[prompt.responder]; @@ -1917,33 +1939,15 @@ function promptTailIsActive(prompt: ParsedPrompt, screen: string): boolean { let end = at; while (end < at + OMO_HINT_LINES && !OMO_HINT_END_RE.test(cleanLines.slice(at, end + 1).join(" ").trim())) end += 1; if (end >= at + OMO_HINT_LINES) return false; - let rules = 0; - let footer = 0; - for (const line of cleanLines.slice(end + 1)) { - if (!line) continue; - if (SOLID_RULE_RE.test(line)) { - if (rules > 0 || footer > 0) return false; - rules = 1; - } else if (rules === 0 || ++footer > OMO_FOOTER_LINES || OMO_NOT_FOOTER_RE.test(line)) return false; - } - return rules === 1; + return omoFooterIsActive(cleanLines.slice(end + 1)); } if (prompt.responder === "omo-pending") { // The widget is live over OmO's input box, other widgets maybe between them: the box (a rule, - // the box's line, empty, a rule) and then nothing but OmO's footer. + // the box's line, empty, a rule) and then OmO's extension status and native footer. const at = findLastIndex(cleanLines, (line, index) => line !== "" && OMO_PENDING_HINT_RE.test(wrapped(cleanLines, index))); const box = cleanLines.findIndex((line, index) => index > at && OMO_EMPTY_BOX_RE.test(line)); if (at < 0 || box < 0 || box - at > 60 || !cleanLines[box - 1]!.startsWith("─")) return false; - let rules = 0; - let footer = 0; - for (const line of cleanLines.slice(box + 1)) { - if (!line) continue; - if (SOLID_RULE_RE.test(line)) { - if (rules > 0 || footer > 0) return false; - rules = 1; - } else if (rules === 0 || ++footer > OMO_FOOTER_LINES || OMO_NOT_FOOTER_RE.test(line)) return false; - } - return rules === 1; + return omoFooterIsActive(cleanLines.slice(box + 1)); } // pi keeps its footer under every dialog — the pane's folder, then its context meter — so the // hint sits near the end without being it. A dialog already answered leaves its hint far @@ -2536,10 +2540,19 @@ export function parseFallbackPrompt(agent: string, screen: string): InteractiveP }, steadyFields(question, body, steady.read))); } if (selectionMenu) { - const above = shown.filter((index) => index < selectionMenu.start).map((index) => cleanLine(lines[index]!)); // the selector's own heading: the block of lines right above its rows, never an earlier question let headEnd = selectionMenu.start; while (headEnd > 0 && (!cleanLine(lines[headEnd - 1]!) || isDivider(lines[headEnd - 1]!))) headEnd -= 1; + // GJC's question dialog has its own opening border before the title. The transcript and + // ticking status above it do not belong to this asking. Its outer borders are unindented + // and identical; Markdown title rules have left padding. Keep every title paragraph inside, + // including approval details; without the matching frame keep the whole screen. + const closing = lines[findLastIndex(lines, (line) => Boolean(line.trim()))]?.trimEnd(); + const border = closing && isDivider(closing) && !/^\s/.test(closing) + ? findLastIndex(lines.slice(0, headEnd), (line) => line.trimEnd() === closing) + : -1; + const dialogShown = border < 0 ? shown : shown.filter((index) => index > border); + const above = dialogShown.filter((index) => index < selectionMenu.start).map((index) => cleanLine(lines[index]!)); let headStart = headEnd; while (headStart > 0 && cleanLine(lines[headStart - 1]!) && !isDivider(lines[headStart - 1]!)) headStart -= 1; const heading = lines.slice(headStart, headEnd).map((line) => cleanLine(line)); @@ -2553,7 +2566,7 @@ export function parseFallbackPrompt(agent: string, screen: string): InteractiveP { label: "Esc", steps: keySteps([KEY.escape]) }, ]; const body = withoutLine(above, question); - return screenCard(lines, shown, steady, finishPrompt(agent, { + return screenCard(lines, dialogShown, steady, finishPrompt(agent, { kind: "menu", fallback: true, title: "Waiting for your answer", question: question ?? "The agent is waiting for your answer.", body, options: choices.map(({ label }) => ({ label, description: null })), @@ -2864,6 +2877,13 @@ function claudeGreyInput(ansi: string): string | null { return suggestion === "" ? null : suggestion; } +/** + * A labeled rule over an input box: Claude's named session (`──── my-session ─`), and OmO's own + * box while it works (`── • Running eval (7s • esc to interrupt) ───`), which herdr reports as + * Claude. Only the box reader takes the shorter lead; the task panel keeps LABELED_RULE_RE. + */ +const BOX_LABELED_RULE_RE = /^─{2,}\s.*─$/; + /** * Claude Code's input box: the rows between the screen's last two rules (a named session labels * its rule). "clipped" when only the bottom rule is on screen, as under a draft taller than the @@ -2872,7 +2892,7 @@ function claudeGreyInput(ansi: string): string | null { function claudeInputBox(screen: string): { plain: string[]; raw: string[] } | "clipped" | null { const raw = screen.split("\n").map((line) => line.replace(/\r$/, "")); const plain = raw.map((line) => line.replace(ANSI_RE, "").replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "").trimEnd()); - const rule = (line: string): boolean => SOLID_RULE_RE.test(line.trim()) || LABELED_RULE_RE.test(line.trim()); + const rule = (line: string): boolean => SOLID_RULE_RE.test(line.trim()) || BOX_LABELED_RULE_RE.test(line.trim()); let end = plain.length - 1; while (end >= 0 && !rule(plain[end]!)) end--; if (end < 0) return null; @@ -2882,6 +2902,19 @@ function claudeInputBox(screen: string): { plain: string[]; raw: string[] } | "c return { plain: plain.slice(start + 1, end), raw: raw.slice(start + 1, end) }; } +/** Claude's new-session tip as a whole input row: `Try "how does work?"`. */ +const CLAUDE_TIP_ROW_RE = /^Try "[^"]+"$/; + +/** Whether an ANSI read sets no color and no dim anywhere, as Claude Code draws under NO_COLOR. */ +function colorless(ansi: string): boolean { + for (const match of ansi.matchAll(/\u001b\[([0-9;:]*)m/g)) { + for (const code of (match[1] || "0").split(/[;:]/).map((value) => Number(value || 0))) { + if (code === 2 || (code >= 30 && code <= 38) || (code >= 40 && code <= 48) || (code >= 90 && code <= 97) || (code >= 100 && code <= 107)) return false; + } + } + return true; +} + /** * Whether Claude Code's input box is not known to be empty, so that a paste would land in what is * there and the Enter after it send both, or run it as a command: typed text, bash mode (`!`), a @@ -2903,6 +2936,9 @@ export function claudeInputDraft(live: string, colors: string | null): boolean { if (colors === null) return true; const shown = claudeInputBox(colors); if (shown === null || shown === "clipped" || shown.plain.join("\n") !== box.plain.join("\n")) return true; + // Under NO_COLOR (#926) the tip is not dim: only its exact form as the box's whole one row, on a + // screen drawn without any color, tells it from typed words. Typed `Try "…"` reads the same there. + if (box.plain.length === 1 && CLAUDE_TIP_ROW_RE.test(box.plain[0]!.slice(1).replace(/\u00a0/g, " ").trim()) && colorless(colors)) return false; let prompt = false; let first = true; let cursor = false; @@ -2955,6 +2991,30 @@ export function claudeClearableDraft(live: string, colors: string | null): strin return draft.trim() === "" ? null : draft; } +/** How much of the box an input_draft refusal shows: enough to tell a draft from Claude's own text. */ +export const INPUT_BOX_SHOWN_CHARS = 200; + +/** + * The live input box (claudeInputBox) as plain text, whole: what a refusal that names no clearable + * draft records, so a send anyway goes only over exactly that box. null when no box could be read + * whole (none on screen, or clipped by the pane): such a box cannot be compared, so nothing is sent over it. + */ +export function claudeInputBoxContent(live: string): string | null { + const box = claudeInputBox(live); + if (box === null || box === "clipped") return null; + return box.plain.map((row) => row.replace(/\u00a0/g, " ").trimEnd()).join("\n").trim(); +} + +/** What the user is shown of a box's content: enough to tell a real draft from a box misread. */ +export function shownInputBox(content: string): string { + return content.length > INPUT_BOX_SHOWN_CHARS ? `${content.slice(0, INPUT_BOX_SHOWN_CHARS)}…` : content; +} + +/** The live input box as a refusal shows it (claudeInputBoxContent, cut by shownInputBox). */ +export function claudeInputBoxText(live: string): string { + return shownInputBox(claudeInputBoxContent(live) ?? ""); +} + /** * The chat's own message back in Claude's box (claudeRestoredDraft), only when the live screen holds * it: a pane scrolled into its history can show an older box with the same words. The viewport read @@ -3098,8 +3158,8 @@ async function liveScreen(paneId: string): Promise { return (await paneRead({ paneId, source: "detection", format: "text" })).text; } -/** omo's form or its widget on a screen, by a line of its key hint: worth a look in the pane's session. */ -const OMO_FORM_RE = /\b1-9 select\b|enter save and next|\btab next question\b|\bor just type your reply\b/; +/** omo's form or widget hint: a narrow pane wraps it, including between "just" and "type". */ +const OMO_FORM_RE = /\b1-9\s+select\b|enter\s+save\s+and\s+next|\btab\s+next\s+question\b|\bor\s+just\s+type\s+your\s+reply\b/; const omoAskReader = new OmoAskReader(); /** Resolve the live process tree every time: a cached session can outlive its agent. */ diff --git a/server/pty/session.test.ts b/server/pty/session.test.ts index 2a4d551fa..415a49bc8 100644 --- a/server/pty/session.test.ts +++ b/server/pty/session.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { PtySession } from "./session.ts"; +import { PtySession, writeLine } from "./session.ts"; /** * The attach command is `herdr terminal attach`, and herdr's CLI picks its socket from @@ -116,3 +116,42 @@ describe("PtySession", () => { } finally { session.kill(); } }, 12000); }); + +describe("writeLine", () => { + // Only a child that really closes its stdin handle reaches EPIPE on Windows: Python's os.close(0) does; + // Bun's and Node's closeSync(0) / stdin.destroy() and an exited child do not, so skip where Python is absent. + // Windows' Microsoft Store alias (WindowsApps\python.exe) is on PATH with no Python behind it: it exits non-zero. + const python = Bun.which(process.platform === "win32" ? "python" : "python3"); + const runs = python !== null && Bun.spawnSync([python, "-c", ""], { stdout: "ignore", stderr: "ignore" }).exitCode === 0; + it.skipIf(!runs)("survives a child that already closed its stdin", async () => { + // on Windows the write settles later and an EPIPE rejects the returned promise instead of throwing + const rejections: unknown[] = []; const track = (error: unknown) => rejections.push(error); + process.on("unhandledRejection", track); + let proc: ReturnType | undefined; + let timer: ReturnType | undefined; + try { + const child = Bun.spawn([ + python!, "-c", + "import os,threading; os.close(0); os.write(1,b'closed\\n'); os.close(1); threading.Event().wait()", + ], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); + proc = child; + const ready = await Promise.race([ + new Response(child.stdout).text(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Child did not close stdin within 2 seconds")), 2000); + }), + ]); + clearTimeout(timer); + expect(ready).toBe("closed\n"); + expect(child.exitCode).toBeNull(); + writeLine(child.stdin, "{}\n"); + await Bun.sleep(300); // fixed wait: observe delayed unhandled rejections after the pipe write + expect(rejections).toEqual([]); + } finally { + clearTimeout(timer); + proc?.kill(); + await proc?.exited; + process.off("unhandledRejection", track); + } + }); +}); diff --git a/server/pty/session.ts b/server/pty/session.ts index 3fe417454..1ac135f23 100644 --- a/server/pty/session.ts +++ b/server/pty/session.ts @@ -13,6 +13,18 @@ export interface PtySessionOptions { env?: Record; } +/** Writes a line to a child's stdin. On Windows a pipe write settles later, so a child that already + * closed its end (EPIPE) rejects the returned promise instead of throwing; swallow both, never crash. */ +export function writeLine(sink: import("bun").FileSink, line: string): boolean { + const quiet = (pending: unknown) => { if (pending instanceof Promise) pending.catch(() => {}); }; + try { + quiet(sink.write(line)); quiet(sink.flush()); + return true; + } catch { + return false; // host already gone + } +} + /** * A command running on a real PTY, hosted by a Node sidecar (see pty-host.mjs for * why output flow control currently needs node-pty, and why it is the prebuilt @@ -66,13 +78,7 @@ export class PtySession { if (this.closed) return false; const sink = this.proc.stdin; if (!sink || typeof sink === "number" || !("write" in sink)) return false; - try { - sink.write(`${JSON.stringify(frame)}\n`); - sink.flush(); - return true; - } catch { - return false; // host already gone - } + return writeLine(sink, `${JSON.stringify(frame)}\n`); } write(data: string): boolean { diff --git a/server/push.test.ts b/server/push.test.ts index c3c7dc72c..622773649 100644 --- a/server/push.test.ts +++ b/server/push.test.ts @@ -168,6 +168,7 @@ describe("push delivery", () => { const [received] = fake.received; expect(received!.payload).toEqual({ pane_id: "w1:p1", + workspace_id: "w1", title: "claude: fix the build", body: "waiting for your input", tag: "herdr-pane-w1:p1", @@ -233,8 +234,10 @@ describe("push delivery", () => { const push = subscribed(); push.seed([pane("w1:p1", "working", "vim notes.md")]); await push.onEnded("w1:p1"); + // the pane is gone from herdr by now: the workspace it was seen in is named, for the tap to open expect(fake.received[0]!.payload).toEqual({ pane_id: "w1:p1", + workspace_id: "w1", title: "vim notes.md", body: "terminal ended", tag: "herdr-pane-w1:p1", @@ -249,10 +252,87 @@ describe("push delivery", () => { expect(await push.sendTest(fake.subscription.endpoint)).toBeNull(); }); + it.each([404, 410])("answers subscription_gone on the first test when the push service returns %i", async (status) => { + const push = subscribed(); + fake.answerWith(status); + + const response = await handlePushRequest(new Request("http://localhost/api/push/test", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ endpoint: fake.subscription.endpoint }), + }), "/api/push/test", push, null); + + expect(response?.status).toBe(410); + expect((await response?.json()).error.code).toBe("subscription_gone"); + }); + + // regression (fails on main): the browser can keep handing over the endpoint its push service retired (#1038) + it("refuses a re-registration of an endpoint its push service retired, so the device subscribes anew", async () => { + const push = subscribed(); + fake.answerWith(410); + expect(await push.sendTest(fake.subscription.endpoint)).toEqual({ ok: false, status: 410, gone: true }); + const post = (path: string, body: unknown) => handlePushRequest(new Request(`http://localhost${path}`, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }), path, push, null); + const again = (await post("/api/push/subscribe", { subscription: fake.subscription }))!; + expect(again.status).toBe(410); + expect(((await again.json()) as { error: { code: string } }).error.code).toBe("subscription_gone"); + expect(JSON.parse(readFileSync(join(stateDir, "push-subscriptions.json"), "utf8"))).toEqual([]); + const tested = (await post("/api/push/test", { endpoint: fake.subscription.endpoint }))!; + expect(tested.status).toBe(410); + expect(((await tested.json()) as { error: { code: string } }).error.code).toBe("subscription_gone"); + // control: an endpoint this server never knew is still just not found + const unknown = (await post("/api/push/test", { endpoint: "https://push.example/never-known" }))!; + expect(unknown.status).toBe(404); + expect(((await unknown.json()) as { error: { code: string } }).error.code).toBe("subscription_not_found"); + }); + + // regression (fails on the merged #1060): a restart between the retirement and "Turn alerts on again" re-registered the dead endpoint + it("still refuses a retired endpoint after a server restart", async () => { + const push = subscribed(); + fake.answerWith(410); + expect(await push.sendTest(fake.subscription.endpoint)).toEqual({ ok: false, status: 410, gone: true }); + const path = join(stateDir, "push-retired.json"); + expect(statSync(path).mode & 0o777).toBe(0o600); + const raw = readFileSync(path, "utf8"); + expect(raw).not.toContain(fake.subscription.endpoint); + const restarted = createPushService({ loopbackHttp: true, stateDir }); + const post = (path: string, body: unknown) => handlePushRequest(new Request(`http://localhost${path}`, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }), path, restarted, null); + const again = (await post("/api/push/subscribe", { subscription: fake.subscription }))!; + expect(again.status).toBe(410); + expect(((await again.json()) as { error: { code: string } }).error.code).toBe("subscription_gone"); + // control: an endpoint this server never retired registers after a restart as before + const other = { ...fake.subscription, endpoint: `${fake.subscription.endpoint}-other` }; + expect((await post("/api/push/subscribe", { subscription: other }))?.status).toBe(204); + }); + + it("forgets a retired endpoint after its time to live and ignores a malformed file", async () => { + let clock = 1_000_000; + const push = createPushService({ loopbackHttp: true, stateDir, now: () => clock }); + push.subscribe(fake.subscription); + fake.answerWith(410); + await push.sendTest(fake.subscription.endpoint); + expect(push.retired(fake.subscription.endpoint)).toBe(true); + clock += 31 * 24 * 60 * 60 * 1000; + expect(createPushService({ loopbackHttp: true, stateDir, now: () => clock }).retired(fake.subscription.endpoint)).toBe(false); + // control: inside the time to live it is still remembered + expect(createPushService({ loopbackHttp: true, stateDir, now: () => 1_000_000 + 1000 }).retired(fake.subscription.endpoint)).toBe(true); + // a malformed file is ignored, never fatal + writeFileSync(join(stateDir, "push-retired.json"), "not json"); + expect(createPushService({ loopbackHttp: true, stateDir }).retired(fake.subscription.endpoint)).toBe(false); + }); + it("keeps a device through a failure that is not a goodbye", async () => { const push = subscribed(); fake.answerWith(500); expect(await push.sendTest(fake.subscription.endpoint)).toEqual({ ok: false, status: 500, gone: false }); + const response = await handlePushRequest(new Request("http://localhost/api/push/test", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ endpoint: fake.subscription.endpoint }), + }), "/api/push/test", push, null); + expect(response?.status).toBe(502); + expect((await response?.json()).error.code).toBe("push_failed"); fake.answerWith(201); expect(await push.sendTest(fake.subscription.endpoint)).toEqual({ ok: true }); }); diff --git a/server/push.ts b/server/push.ts index ad9e9c0b2..7036f7db0 100644 --- a/server/push.ts +++ b/server/push.ts @@ -8,6 +8,10 @@ * orphans every subscribed device, so a malformed file is an error, never a rotation. * - push-subscriptions.json: survives restarts, so a reboot does not unsubscribe phones. * A 404/410 from a push service means the device dropped it; the record is deleted. + * - push-retired.json: the endpoint is also remembered, as a hash only (`RETIRED_LIMIT`, `RETIRED_TTL_MS`), + * so a restart does not forget it: a browser can keep handing over a subscription its push + * service retired, and registering it again would only fail the next push, so it is refused as + * `subscription_gone` and the device subscribes anew (#1038). * * Requests are built by web-push's generateRequestDetails (RFC 8291 aes128gcm + RFC * 8292 VAPID) and sent with fetch; its sendNotification path goes through node:https @@ -27,6 +31,7 @@ * would go out, so a window brought forward while the alert waits still holds it back. */ +import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import webpush from "web-push"; @@ -50,6 +55,10 @@ import { isLoopbackHost } from "./access.ts"; const PUSH_TTL_SECONDS = 12 * 60 * 60; const PUSH_TIMEOUT_MS = 10_000; const DEFAULT_SUBJECT = "https://github.com/devswha/herdr-web-ui"; +/** How many retired endpoints are remembered; the oldest is forgotten first. */ +const RETIRED_LIMIT = 1000; +/** How long a retired endpoint is remembered: a browser that kept it this long has long since moved on. */ +const RETIRED_TTL_MS = 30 * 24 * 60 * 60 * 1000; /** How long an alert waits for the pane to change first, and the turn a finish is worth telling. */ export interface AlertTiming { @@ -82,6 +91,8 @@ export interface PushService { subscribe(subscription: PushSubscriptionRecord, alerts?: AlertPrefs, deviceId?: string | null): void; revokeDevice(id: string): void; unsubscribe(endpoint: string): void; + /** whether a push service answered 404/410 for this endpoint since the server started */ + retired(endpoint: string): boolean; /** One confirmation push to one device, so enabling alerts proves the whole path works. */ sendTest(endpoint: string): Promise; /** The collector's view of every pane: status baselines and the titles notifications use. */ @@ -179,6 +190,13 @@ export function createPushService(options: PushServiceOptions): PushService { let subscriptions: Map | null = null; const lastStatus = new Map(); const titles = new Map(); + // the workspace a pane was last seen in: an alert names it, so the tap of an ended pane can open + // its workspace (src/lib/deepLink.ts resolveLink) once herdr has dropped the pane + const workspaces = new Map(); + const retiredPath = join(options.stateDir, "push-retired.json"); + /** sha256 of each endpoint a push service retired -> when, oldest first; loaded on first need */ + let retiredEndpoints: Map | null = null; + const endpointHash = (endpoint: string): string => createHash("sha256").update(endpoint).digest("base64url"); /** Created on first need: a server nobody subscribes to never writes a key. */ function keys(): { publicKey: string; privateKey: string } { @@ -213,6 +231,24 @@ export function createPushService(options: PushServiceOptions): PushService { return subscriptions; } + function retiredStore(): Map { + if (retiredEndpoints) return retiredEndpoints; + retiredEndpoints = new Map(); + let stored: unknown; + try { + stored = readJson(retiredPath); + } catch { + return retiredEndpoints; // an unreadable file only means a retired endpoint registers again once + } + if (!Array.isArray(stored)) return retiredEndpoints; + const cutoff = (options.now ?? Date.now)() - RETIRED_TTL_MS; + for (const entry of stored.slice(-RETIRED_LIMIT)) { + const { hash, at } = (entry ?? {}) as { hash?: unknown; at?: unknown }; + if (typeof hash === "string" && typeof at === "number" && at > cutoff) retiredEndpoints.set(hash, at); + } + return retiredEndpoints; + } + function persist(): void { writeJsonPrivate(subscriptionsPath, [...store().values()]); } @@ -247,6 +283,12 @@ export function createPushService(options: PushServiceOptions): PushService { // 404/410: the device unsubscribed or the browser dropped the subscription const gone = status === 404 || status === 410; if (gone) { + const hash = endpointHash(subscription.endpoint); + const retired = retiredStore(); + retired.delete(hash); + retired.set(hash, (options.now ?? Date.now)()); + if (retired.size > RETIRED_LIMIT) retired.delete(retired.keys().next().value!); + writeJsonPrivate(retiredPath, [...retired].map(([key, at]) => ({ hash: key, at }))); if (store().delete(subscription.endpoint)) persist(); } else { console.error(`web push to ${new URL(subscription.endpoint).host} failed: ${status ?? "no response"}`); @@ -264,6 +306,12 @@ export function createPushService(options: PushServiceOptions): PushService { ); } + /** Where an alert's pane is: its PC (absent for this one) and the workspace it was last seen in. */ + function where(key: string, machineId: string): Pick { + const workspace = workspaces.get(key); + return { ...(machineId === "local" ? {} : { machine_id: machineId }), ...(workspace ? { workspace_id: workspace } : {}) }; + } + async function titleOf(paneId: string): Promise { try { const fresh = await options.lookupTitle?.(paneId); @@ -345,6 +393,8 @@ export function createPushService(options: PushServiceOptions): PushService { if (store().delete(endpoint)) persist(); }, + retired: (endpoint) => retiredStore().has(endpointHash(endpoint)), + async sendTest(endpoint) { const subscription = store().get(endpoint); if (!subscription) return null; @@ -356,6 +406,7 @@ export function createPushService(options: PushServiceOptions): PushService { // only fill gaps: an event already seen is newer than any snapshot const key = paneStorageId(machineId, pane.pane_id); titles.set(key, `${machineName ? machineName + " · " : ""}${paneTitle(pane)}`); + workspaces.set(key, pane.workspace_id); if (!lastStatus.has(key)) lastStatus.set(key, pane.agent_status); } }, @@ -423,7 +474,7 @@ export function createPushService(options: PushServiceOptions): PushService { const current = store().get(subscription.endpoint); return current && current.device_id === subscription.device_id ? [current] : []; }); - await broadcast({ ...statusMessage(paneId, title, status), ...(machineId === "local" ? {} : { machine_id: machineId }), tag: paneNotificationTag(paneId, machineId) }, status === "blocked" ? "high" : "normal", live); + await broadcast({ ...statusMessage(paneId, title, status), ...where(key, machineId), tag: paneNotificationTag(paneId, machineId) }, status === "blocked" ? "high" : "normal", live); }); }, @@ -435,7 +486,7 @@ export function createPushService(options: PushServiceOptions): PushService { const to = [...store().values()].filter((subscription) => (subscription.alerts ?? DEFAULT_ALERTS).done !== "off"); if (to.length === 0 || options.inUse?.()) return; // the pane may already be gone from herdr: the seeded title is what is left - await broadcast({ ...endedMessage(paneId, titles.get(key) ?? paneId), ...(machineId === "local" ? {} : { machine_id: machineId }), tag: paneNotificationTag(paneId, machineId) }, "normal", to); + await broadcast({ ...endedMessage(paneId, titles.get(key) ?? paneId), ...where(key, machineId), tag: paneNotificationTag(paneId, machineId) }, "normal", to); }, async settled() { @@ -449,9 +500,14 @@ export function createPushService(options: PushServiceOptions): PushService { * a subscription receives pane titles until it is removed. * GET /api/push -> { public_key } * POST /api/push/subscribe { subscription, alerts? } -> 204 (alerts: this device's AlertPrefs) + * | 410 subscription_gone (its push service retired the endpoint: subscribe anew) * DELETE /api/push/subscribe { endpoint } -> 204 - * POST /api/push/test { endpoint } -> 204 | 404 subscription_not_found | 502 push_failed + * POST /api/push/test { endpoint } -> 204 | 404 subscription_not_found | 410 subscription_gone | 502 push_failed */ +function subscriptionGone(): Response { + return jsonResponse({ error: { code: "subscription_gone", message: "the push service retired this subscription; subscribe again" } }, 410); +} + export async function handlePushRequest(request: Request, pathname: string, push: PushService, deviceId: string | null | undefined): Promise { const route = `${request.method} ${pathname}`; if (route === "GET /api/push") return jsonResponse({ public_key: push.publicKey() }); @@ -471,6 +527,7 @@ export async function handlePushRequest(request: Request, pathname: string, push if (route === "POST /api/push/subscribe") { const subscription = push.parse(body.subscription); if (!subscription) return badRequest("invalid_subscription", "subscription needs an https endpoint and p256dh/auth keys"); + if (push.retired(subscription.endpoint)) return subscriptionGone(); push.subscribe(subscription, body.alerts === undefined ? undefined : parseAlerts(body.alerts), deviceId); return new Response(null, { status: 204 }); } @@ -480,7 +537,11 @@ export async function handlePushRequest(request: Request, pathname: string, push return new Response(null, { status: 204 }); } const delivery = await push.sendTest(body.endpoint); - if (!delivery) return jsonResponse({ error: { code: "subscription_not_found", message: "this device is not subscribed" } }, 404); + if (!delivery) { + return push.retired(body.endpoint) ? subscriptionGone() + : jsonResponse({ error: { code: "subscription_not_found", message: "this device is not subscribed" } }, 404); + } if (delivery.ok) return new Response(null, { status: 204 }); + if (delivery.gone) return subscriptionGone(); return jsonResponse({ error: { code: "push_failed", message: `push service answered ${delivery.status ?? "nothing"}` } }, 502); } diff --git a/server/remote-entry.test.ts b/server/remote-entry.test.ts new file mode 100644 index 000000000..14cb93dad --- /dev/null +++ b/server/remote-entry.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join as winJoin } from "node:path/win32"; + +const entryLines = readFileSync(new URL("./remote-entry.ts", import.meta.url), "utf8").split("\n"); +const herdrConfigLine = entryLines.find((line) => line.startsWith("const herdrConfig =")); +const sessionLine = entryLines.find((line) => line.startsWith('const session = process.env["HERDR_REMOTE_SESSION"]')); +const socketLine = entryLines.find((line) => line.startsWith('process.env["HERDR_SOCKET"] =')); +if (!herdrConfigLine || !sessionLine || !socketLine) throw new Error("remote-entry socket setup was not found"); + +function windowsSocket(env: Record): string { + const evaluateSocket = new Function( + "windows", + "process", + "join", + "homedir", + `${herdrConfigLine}\n${sessionLine}\n${socketLine}\nreturn process.env["HERDR_SOCKET"];`, + ); + return String(evaluateSocket(true, { env }, winJoin, () => "C:\\Users\\u")); +} + +describe("remote entry socket selection", () => { + it("uses XDG_CONFIG_HOME before APPDATA on Windows", () => { + // regression (fails on main): a set XDG_CONFIG_HOME owns herdr's Windows socket root + expect(windowsSocket({ XDG_CONFIG_HOME: "C:\\Temp\\xdg", APPDATA: "C:\\Users\\u\\AppData\\Roaming", HERDR_REMOTE_SESSION: "qa" })) + .toBe("C:\\Temp\\xdg\\herdr\\sessions\\qa\\herdr.sock"); + }); + + it("uses APPDATA when Windows XDG_CONFIG_HOME is absent", () => { + // control: APPDATA stays the Windows fallback + expect(windowsSocket({ APPDATA: "C:\\Users\\u\\AppData\\Roaming", HERDR_REMOTE_SESSION: "qa" })) + .toBe("C:\\Users\\u\\AppData\\Roaming\\herdr\\sessions\\qa\\herdr.sock"); + }); + + it("uses the roaming profile when both Windows config variables are absent", () => { + // control: preserve the standard Windows profile fallback + expect(windowsSocket({ HERDR_REMOTE_SESSION: "qa" })) + .toBe("C:\\Users\\u\\AppData\\Roaming\\herdr\\sessions\\qa\\herdr.sock"); + }); +}); diff --git a/server/remote-entry.ts b/server/remote-entry.ts index b7afa7691..346b640f1 100644 --- a/server/remote-entry.ts +++ b/server/remote-entry.ts @@ -18,8 +18,8 @@ process.env["PATH"] = `${join(bundle, "bin")}${delimiter}${process.env["PATH"] ? const session = process.env["HERDR_REMOTE_SESSION"]; // herdr puts its sockets under XDG_CONFIG_HOME when it is set (GitHub's Linux runners and some // desktops set it), so the bridge must look where the daemon it starts will listen; on -// Windows they live under %APPDATA%\herdr (`herdr status server`) -const herdrConfig = windows ? join(process.env["APPDATA"] || join(homedir(), "AppData", "Roaming"), "herdr") : join(process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config"), "herdr"); +// Windows they live under XDG_CONFIG_HOME when set, then %APPDATA%\herdr (`herdr status server`) +const herdrConfig = windows ? join(process.env["XDG_CONFIG_HOME"] || process.env["APPDATA"] || join(homedir(), "AppData", "Roaming"), "herdr") : join(process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config"), "herdr"); process.env["HERDR_SOCKET"] = session ? join(herdrConfig, "sessions", session, "herdr.sock") : join(herdrConfig, "herdr.sock"); function staleWindowsMarker(path: string): boolean { return staleMarker(readFileSync(path, "utf8"), (pid) => { diff --git a/server/remote-host.ts b/server/remote-host.ts index bf1069da0..4058a8f6f 100644 --- a/server/remote-host.ts +++ b/server/remote-host.ts @@ -12,6 +12,7 @@ * - An administrator's authorized keys live in %ProgramData%\ssh\administrators_authorized_keys * with a fixed ACL; everyone else's in ~\.ssh\authorized_keys. */ +import { win32 } from "node:path"; import { REMOTE_BUNDLE_VERSION } from "../shared/machines.ts"; import type { BridgeDescriptor } from "./bridge.ts"; import { shellQuote } from "./machine-security.ts"; @@ -26,6 +27,26 @@ export const WINDOWS_BUNDLE_BASE = "$env:LOCALAPPDATA\\herdr-web-ui"; export const UNSUPPORTED_HOST = "Only Linux, macOS (x64 or arm64) and Windows (x64) PCs are supported."; export const HERDR_INSTALL_CMD = "https://herdr.dev/install.cmd"; +/** the upload is cut off after this long on the PC, as a dead SSH link may never deliver EOF */ +export const INSTALL_UPLOAD_TIMEOUT_S = 25 * 60; +/** + * Takes `$base/install.lock` (a directory holding the owner's PID). A lock whose owner is + * gone, or that outlived the longest possible install (the local SSH timeout is 30 minutes), + * was left by an install that never reached its trap; it is reclaimed rather than blocking + * every retry until someone deletes it by hand. Expects `$base`; sets `$lock`. + * + * Reclaiming happens under `install.lock.reclaim`, a second mkdir mutex, with the owner and + * age read again inside it: two installers that both judged the lock stale cannot then + * delete each other's fresh lock. A mutex left by a reclaimer that died is dropped after two + * minutes, and that run reports "in progress"; the retry takes over. + * The lock directory exists for an instant before its PID file is written. In that window + * other installers see it as in progress, and if its creator dies there, only the age check + * can reclaim it. + */ +export const POSIX_INSTALL_LOCK = `lock="$base/install.lock"; if ! mkdir "$lock" 2>/dev/null; then mkdir "$lock.reclaim" 2>/dev/null || { test -z "$(find "$lock.reclaim" -maxdepth 0 -mmin +2 2>/dev/null)" || rmdir "$lock.reclaim" 2>/dev/null; printf 'Another installation is in progress; retry shortly\\n' >&2; exit 1; }; owner=$(cat "$lock/pid" 2>/dev/null || true); if { test -n "$owner" && ! kill -0 "$owner" 2>/dev/null; } || test -n "$(find "$lock" -maxdepth 0 -mmin +35 2>/dev/null)"; then rm -rf "$lock"; mkdir "$lock" 2>/dev/null || { rmdir "$lock.reclaim"; printf 'Another installation is in progress; retry shortly\\n' >&2; exit 1; }; printf '%s' "$$" > "$lock/pid"; rmdir "$lock.reclaim"; else rmdir "$lock.reclaim"; printf 'Another installation is in progress; retry shortly\\n' >&2; exit 1; fi; fi; printf '%s' "$$" > "$lock/pid";`; +/** the exit trap's release: only a lock this shell still owns, never a successor's */ +export const POSIX_INSTALL_RELEASE = `test "$(cat "$lock/pid" 2>/dev/null)" != "$$" || rm -rf "$lock"`; + export interface HostInspection { platform: "linux-x64" | "linux-arm64" | "darwin-x64" | "darwin-arm64" | "win32-x64"; home: string; @@ -102,7 +123,7 @@ export const posixHost: RemoteHost = { const { asset, input } = await fetchBundle(platform, signal, options); // Verify on BOTH hosts, extract into staging and atomically rename. No user-wide // Bun/Node/herdr installation is changed; the complete runtime lives in this dir. - await ssh.run(`set -eu; umask 077; base="$HOME/.local/share/herdr-web-ui"; mkdir -p "$base"; mkdir "$base/install.lock" || { printf 'Another installation is in progress; retry shortly\\n' >&2; exit 1; }; tmp=$(mktemp -d "$base/install.XXXXXX"); trap 'rm -rf "$tmp"; rmdir "$base/install.lock"' EXIT HUP INT TERM; cat > "$tmp/bundle.tgz"; if command -v sha256sum >/dev/null; then actual=$(sha256sum "$tmp/bundle.tgz" | cut -d ' ' -f 1); else actual=$(shasum -a 256 "$tmp/bundle.tgz" | cut -d ' ' -f 1); fi; test "$actual" = ${shellQuote(asset.sha256)}; mkdir "$tmp/runtime"; tar xzf "$tmp/bundle.tgz" -C "$tmp/runtime"; test -x "$tmp/runtime/bin/bun"; test -x "$tmp/runtime/bin/node"; test -x "$tmp/runtime/bin/herdr"; "$tmp/runtime/bin/bun" --version; "$tmp/runtime/bin/herdr" --version; "$tmp/runtime/bin/node" "$tmp/runtime/server/pty/smoke.mjs"; release="$HOME/${BUNDLE_DIR}-${asset.sha256.slice(0, 16)}"; if test ! -d "$release"; then mv "$tmp/runtime" "$release"; fi; ln -s "$release" "$tmp/current"; if test -d "$HOME/${BUNDLE_DIR}" && test ! -L "$HOME/${BUNDLE_DIR}"; then mv "$HOME/${BUNDLE_DIR}" "$HOME/${BUNDLE_DIR}-legacy-$(date +%s)"; fi; "$release/bin/bun" -e 'require("node:fs").renameSync(process.argv[1], process.argv[2])' "$tmp/current" "$HOME/${BUNDLE_DIR}"`, input, 30 * 60_000); + await ssh.run(`set -eu; umask 077; base="$HOME/.local/share/herdr-web-ui"; mkdir -p "$base"; ${POSIX_INSTALL_LOCK} tmp=$(mktemp -d "$base/install.XXXXXX"); trap 'rm -rf "$tmp"; ${POSIX_INSTALL_RELEASE}' EXIT HUP INT TERM; if command -v timeout >/dev/null; then timeout ${INSTALL_UPLOAD_TIMEOUT_S} cat > "$tmp/bundle.tgz"; else cat > "$tmp/bundle.tgz"; fi; if command -v sha256sum >/dev/null; then actual=$(sha256sum "$tmp/bundle.tgz" | cut -d ' ' -f 1); else actual=$(shasum -a 256 "$tmp/bundle.tgz" | cut -d ' ' -f 1); fi; test "$actual" = ${shellQuote(asset.sha256)}; mkdir "$tmp/runtime"; tar xzf "$tmp/bundle.tgz" -C "$tmp/runtime"; test -x "$tmp/runtime/bin/bun"; test -x "$tmp/runtime/bin/node"; test -x "$tmp/runtime/bin/herdr"; "$tmp/runtime/bin/bun" --version; "$tmp/runtime/bin/herdr" --version; "$tmp/runtime/bin/node" "$tmp/runtime/server/pty/smoke.mjs"; release="$HOME/${BUNDLE_DIR}-${asset.sha256.slice(0, 16)}"; if test ! -d "$release"; then mv "$tmp/runtime" "$release"; fi; ln -s "$release" "$tmp/current"; if test -d "$HOME/${BUNDLE_DIR}" && test ! -L "$HOME/${BUNDLE_DIR}"; then mv "$HOME/${BUNDLE_DIR}" "$HOME/${BUNDLE_DIR}-legacy-$(date +%s)"; fi; "$release/bin/bun" -e 'require("node:fs").renameSync(process.argv[1], process.argv[2])' "$tmp/current" "$HOME/${BUNDLE_DIR}"`, input, 30 * 60_000); }, stop: async (ssh, pid) => { await ssh.run(`kill -TERM ${pid}`); }, async start(ssh, { session, herdrPath, inspection }) { @@ -147,20 +168,23 @@ export const windowsHost: RemoteHost = { // which the next update replaces); a fresh sshd session may not have the PATH it added yet "$herdr = \"$env:LOCALAPPDATA\\Programs\\Herdr\\bin\\herdr.exe\"", "if (-not (Test-Path $herdr)) { $herdr = (Get-Command herdr.exe -ErrorAction SilentlyContinue).Source }", - "Write-Output 'Windows'; Write-Output $env:PROCESSOR_ARCHITECTURE; Write-Output $env:USERPROFILE; Write-Output $env:APPDATA; Write-Output $env:LOCALAPPDATA; Write-Output \"$herdr\"", + "Write-Output 'Windows'; Write-Output $env:PROCESSOR_ARCHITECTURE; Write-Output $env:USERPROFILE; Write-Output $env:APPDATA; if ($env:XDG_CONFIG_HOME) { Write-Output $env:XDG_CONFIG_HOME } else { Write-Output '' }; Write-Output $env:LOCALAPPDATA; Write-Output \"$herdr\"", `if (Test-Path "${WINDOWS_BUNDLE}\\bin\\bun.exe") { Write-Output 'bundle-ready' }`, `if (Get-ChildItem "${WINDOWS_BUNDLE_BASE}" -Directory -Filter 'remote-v*' -ErrorAction SilentlyContinue | Where-Object { Test-Path "$($_.FullName)\\bin\\bun.exe" }) { Write-Output 'bundle-older' }`, WINDOWS_DESCRIPTORS, ].join("\n")); - const [os, arch, home, appData, localAppData, herdrPath, ...lines] = inspection.split("\n"); + const [os, arch, home, appData, xdgConfigHome, localAppData, herdrPath, ...lines] = inspection.split("\n"); if (os !== "Windows" || arch !== "AMD64") throw new Error(UNSUPPORTED_HOST); - const drive = /^[A-Za-z]:\\/; - if (!home || !drive.test(home) || !appData || !drive.test(appData) || !localAppData || !drive.test(localAppData)) throw new Error("The SSH account's profile directory (%USERPROFILE%) could not be read on this PC"); - const herdrDir = `${appData}\\herdr`; + const drive = /^[A-Za-z]:[\\/]/; + if (xdgConfigHome && !drive.test(xdgConfigHome)) throw new Error("The SSH account's XDG_CONFIG_HOME could not be read on this PC"); + if (!home || !drive.test(home) || !localAppData || !drive.test(localAppData)) throw new Error("The SSH account's profile directory (%USERPROFILE%) could not be read on this PC"); + const herdrConfig = xdgConfigHome || appData; + if (!herdrConfig || !drive.test(herdrConfig)) throw new Error("The SSH account's herdr configuration directory could not be read on this PC"); + const herdrDir = win32.join(herdrConfig, "herdr"); return { platform: "win32-x64", home, herdrPath: herdrPath ?? "", // the marker file's own path: the bridge's realpath of it is the same string (live-verified) - expectedSocket: session ? `${herdrDir}\\sessions\\${session}\\herdr.sock` : `${herdrDir}\\herdr.sock`, + expectedSocket: session ? win32.join(herdrDir, "sessions", session, "herdr.sock") : win32.join(herdrDir, "herdr.sock"), bundleReady: lines.includes("bundle-ready"), bundleOlder: lines.includes("bundle-older"), descriptors: parseDescriptors(lines), runtimeDir: `${localAppData}\\herdr-web-ui\\remote-v${REMOTE_BUNDLE_VERSION}`, registryDir: `${home}\\.config\\herdr-web-ui\\bridges`, diff --git a/server/repo-diff.test.ts b/server/repo-diff.test.ts new file mode 100644 index 000000000..82ae29012 --- /dev/null +++ b/server/repo-diff.test.ts @@ -0,0 +1,414 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parsePatchFiles } from "@pierre/diffs"; + +import type { ApiError, RepoDiffFileList, RepoDiffFilePatch } from "../shared/protocol.ts"; +import { DeviceStore } from "./devices.ts"; +import { createServer } from "./index.ts"; +import { REPO_DIFF_CONTEXT_LINES, REPO_DIFF_MAX_FILES, REPO_DIFF_MAX_LIST_BYTES, REPO_DIFF_MAX_PATCH_BYTES, repoDiffFile, repoDiffFiles } from "./repo-diff.ts"; + +/** + * Real git repositories in a temp folder: the routes are only as right as what git prints, so a + * stand-in git would prove nothing here. The developer's own git configuration is kept out, for + * the fixtures and for the code under test alike. + */ +process.env["GIT_CONFIG_GLOBAL"] = "/dev/null"; +process.env["GIT_CONFIG_NOSYSTEM"] = "1"; + +const scratch = realpathSync(mkdtempSync(join(tmpdir(), "herdr-repo-diff-"))); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); + +function git(cwd: string, ...args: string[]): string { + const result = Bun.spawnSync(["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", "-C", cwd, ...args], { stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString()}`); + return result.stdout.toString(); +} + +function repository(name: string): string { + const root = join(scratch, name); + mkdirSync(root); + git(root, "init", "-q", "-b", "main"); + return root; +} + +const query = (values: Record = {}) => new URLSearchParams(values); +const lines = (count: number, prefix = "line") => Array.from({ length: count }, (_, index) => `${prefix} ${index + 1}\n`).join(""); +/** What Pierre makes of a patch: the parser the diff view uses, told to throw on anything malformed. */ +const parsed = (patch: string | null) => parsePatchFiles(patch ?? "", undefined, true).flatMap((entry) => entry.files); + +const UNICODE = "dir with space/ünï 文件.txt"; + +describe("workspace and staged changes", () => { + let root: string; + beforeAll(() => { + root = repository("changes"); + mkdirSync(join(root, "dir with space")); + writeFileSync(join(root, "keep.txt"), "a\nb\nc\n"); + writeFileSync(join(root, "gone.txt"), "going\n"); + writeFileSync(join(root, "moved-from.txt"), lines(12)); + writeFileSync(join(root, "pic.bin"), new Uint8Array([0, 1, 2, 3, 0, 255])); + writeFileSync(join(root, UNICODE), "eins\n"); + git(root, "add", "."); + git(root, "commit", "-q", "-m", "first"); + // staged: a rename with an edit, and a new file + git(root, "mv", "moved-from.txt", "moved-to.txt"); + writeFileSync(join(root, "moved-to.txt"), lines(12).replace("line 3\n", "line three\n")); + writeFileSync(join(root, "staged-new.txt"), "fresh\n"); + git(root, "add", "moved-to.txt", "staged-new.txt"); + // unstaged + writeFileSync(join(root, "keep.txt"), "a\nB\nc\nd\n"); + rmSync(join(root, "gone.txt")); + writeFileSync(join(root, "pic.bin"), new Uint8Array([9, 0, 9, 0])); + writeFileSync(join(root, UNICODE), "zwei\n"); + writeFileSync(join(root, "new file.txt"), "one\ntwo\n"); + writeFileSync(join(root, "blob.bin"), new Uint8Array([1, 0, 2, 0])); + }); + + it("lists unstaged and untracked files with status, counts and the repository they are in", async () => { + const list = await repoDiffFiles(root, query()); + expect(list.repository).toEqual({ root, branch: "main", head: git(root, "rev-parse", "HEAD").trim(), detached: false, unborn: false }); + expect(list).toMatchObject({ scope: "workspace", base: null, head: null, total: 6, truncated: false, limits: { max_files: REPO_DIFF_MAX_FILES, max_list_bytes: REPO_DIFF_MAX_LIST_BYTES } }); + expect(list.files).toEqual([ + { path: "blob.bin", old_path: null, status: "untracked", additions: null, deletions: null, binary: null }, + { path: UNICODE, old_path: null, status: "modified", additions: 1, deletions: 1, binary: false }, + { path: "gone.txt", old_path: null, status: "deleted", additions: 0, deletions: 1, binary: false }, + { path: "keep.txt", old_path: null, status: "modified", additions: 2, deletions: 1, binary: false }, + { path: "new file.txt", old_path: null, status: "untracked", additions: null, deletions: null, binary: null }, + { path: "pic.bin", old_path: null, status: "modified", additions: null, deletions: null, binary: true }, + ]); + }); + + it("answers the same list from a folder below the repository's top", async () => { + const list = await repoDiffFiles(join(root, "dir with space"), query()); + expect(list.repository.root).toBe(root); + expect(list.files.map((file) => file.path)).toContain("keep.txt"); + }); + + it("lists staged files apart, a rename with where it came from", async () => { + const list = await repoDiffFiles(root, query({ scope: "staged" })); + expect(list.files).toEqual([ + { path: "moved-to.txt", old_path: "moved-from.txt", status: "renamed", additions: 1, deletions: 1, binary: false }, + { path: "staged-new.txt", old_path: null, status: "added", additions: 1, deletions: 0, binary: false }, + ]); + }); + + it("gives each text file one whole patch Pierre parses: edit, deletion, untracked, addition, rename", async () => { + const edit = await repoDiffFile(root, query({ path: "keep.txt" })); + expect(edit).toMatchObject({ unavailable: null, truncated: false, file: { path: "keep.txt", status: "modified", additions: 2, deletions: 1 }, limits: { max_patch_bytes: REPO_DIFF_MAX_PATCH_BYTES, context_lines: REPO_DIFF_CONTEXT_LINES } }); + expect(edit.patch).toStartWith("diff --git a/keep.txt b/keep.txt\n"); + expect(parsed(edit.patch).map((file) => [file.name, file.type, file.hunks.length])).toEqual([["keep.txt", "change", 1]]); + + const deleted = await repoDiffFile(root, query({ path: "gone.txt" })); + expect(parsed(deleted.patch).map((file) => [file.name, file.type])).toEqual([["gone.txt", "deleted"]]); + + const untracked = await repoDiffFile(root, query({ path: "new file.txt" })); + expect(untracked.file).toEqual({ path: "new file.txt", old_path: null, status: "untracked", additions: null, deletions: null, binary: false }); + expect(parsed(untracked.patch).map((file) => [file.name, file.type, file.additionLines])).toEqual([["new file.txt", "new", ["one\n", "two\n"]]]); + + const added = await repoDiffFile(root, query({ scope: "staged", path: "staged-new.txt" })); + expect(parsed(added.patch).map((file) => [file.name, file.type])).toEqual([["staged-new.txt", "new"]]); + + const renamed = await repoDiffFile(root, query({ scope: "staged", path: "moved-to.txt", old_path: "moved-from.txt" })); + expect(renamed.file).toMatchObject({ status: "renamed", old_path: "moved-from.txt" }); + expect(parsed(renamed.patch).map((file) => [file.name, file.prevName, file.type])).toEqual([["moved-to.txt", "moved-from.txt", "rename-changed"]]); + }); + + it("keeps a path with spaces and non-ASCII letters as it is, in the list and in the patch", async () => { + const answer = await repoDiffFile(root, query({ path: UNICODE })); + expect(answer.file.path).toBe(UNICODE); + expect(answer.patch).toStartWith(`diff --git a/${UNICODE} b/${UNICODE}\n`); + expect(parsed(answer.patch).map((file) => [file.name, file.deletionLines, file.additionLines])).toEqual([[UNICODE, ["eins\n"], ["zwei\n"]]]); + }); + + it("says a binary file is binary instead of sending a patch, tracked or not", async () => { + for (const path of ["pic.bin", "blob.bin"]) { + const answer = await repoDiffFile(root, query({ path })); + expect(answer).toMatchObject({ patch: null, unavailable: "binary", truncated: false, file: { path, binary: true } }); + } + }); + + it("refuses a path that is not one inside the repository, and one where nothing changed", async () => { + for (const path of ["", "../outside.txt", "/etc/hosts", "a//b", "dir/../keep.txt", "./keep.txt", "keep.txt\0"]) { + await expect(repoDiffFile(root, query({ path }))).rejects.toMatchObject({ code: "invalid_path", status: 400 }); + } + await expect(repoDiffFile(root, query({ path: "keep.txt", old_path: "../x" }))).rejects.toMatchObject({ code: "invalid_path", status: 400 }); + // pathspec magic and globs are read as plain names + for (const path of ["staged-new.txt", "*.txt", ":(glob)**", ":/keep.txt"]) { + await expect(repoDiffFile(root, query({ path }))).rejects.toMatchObject({ code: "not_found", status: 404 }); + } + }); + + it("refuses an unknown scope, and revisions outside the commit scope", async () => { + await expect(repoDiffFiles(root, query({ scope: "everything" }))).rejects.toMatchObject({ code: "invalid_scope", status: 400 }); + await expect(repoDiffFiles(root, query({ scope: "staged", base: "HEAD" }))).rejects.toMatchObject({ code: "invalid_revision", status: 400 }); + }); + + it("refuses an unrelated old path instead of returning another file's patch first", async () => { + await expect(repoDiffFile(root, query({ path: "keep.txt", old_path: UNICODE }))).rejects.toMatchObject({ code: "invalid_path", status: 400 }); + }); + + it("refuses stale rename metadata after the original path is reused", async () => { + const root = repository("reused-rename-path"); + writeFileSync(join(root, "old.txt"), lines(12)); + git(root, "add", "."); + git(root, "commit", "-q", "-m", "first"); + git(root, "mv", "old.txt", "new.txt"); + writeFileSync(join(root, "old.txt"), "replacement\n"); + git(root, "add", "."); + await expect(repoDiffFile(root, query({ scope: "staged", path: "new.txt", old_path: "old.txt" }))).rejects.toMatchObject({ code: "invalid_path", status: 400 }); + const answer = await repoDiffFile(root, query({ scope: "staged", path: "new.txt" })); + expect(parsed(answer.patch).map((file) => file.name)).toEqual(["new.txt"]); + }); +}); + +describe("comparing two commits", () => { + let root: string; + let first: string; + let second: string; + beforeAll(() => { + root = repository("commits"); + writeFileSync(join(root, "a.txt"), "one\n"); + git(root, "add", "."); + git(root, "commit", "-q", "-m", "first"); + first = git(root, "rev-parse", "HEAD").trim(); + writeFileSync(join(root, "a.txt"), "one\ntwo\n"); + writeFileSync(join(root, "b.txt"), "new\n"); + git(root, "add", "."); + git(root, "commit", "-q", "-m", "second"); + second = git(root, "rev-parse", "HEAD").trim(); + git(root, "tag", "v1", first); + // uncommitted work is no part of a comparison between commits + writeFileSync(join(root, "a.txt"), "dirty\n"); + }); + + it("lists and patches what differs between the two, named as full hashes", async () => { + const comparisons: Record[] = [{ base: "HEAD~1" }, { base: "v1", head: "main" }, { base: first, head: second }]; + for (const revisions of comparisons) { + const list = await repoDiffFiles(root, query({ scope: "commit", ...revisions })); + expect(list).toMatchObject({ scope: "commit", base: first, head: second, total: 2, truncated: false }); + expect(list.files.map((file) => [file.path, file.status, file.additions, file.deletions])).toEqual([["a.txt", "modified", 1, 0], ["b.txt", "added", 1, 0]]); + } + const answer = await repoDiffFile(root, query({ scope: "commit", base: first, head: second, path: "a.txt" })); + expect(answer).toMatchObject({ base: first, head: second, unavailable: null }); + expect(parsed(answer.patch).map((file) => [file.name, file.additionLines.includes("two\n"), file.additionLines.includes("dirty\n")])).toEqual([["a.txt", true, false]]); + expect((await repoDiffFiles(root, query({ scope: "commit", base: second, head: second }))).files).toEqual([]); + }); + + it("refuses a revision that is an option, a range or malformed, and one that names no commit", async () => { + const marker = join(scratch, "option-ran"); + for (const base of [`--output=${marker}`, "-p", "HEAD..main", "HEAD:a.txt", "HEAD main", "", "x".repeat(201)]) { + await expect(repoDiffFiles(root, query({ scope: "commit", base }))).rejects.toMatchObject({ code: "invalid_revision", status: 400 }); + } + expect(existsSync(marker)).toBeFalse(); + await expect(repoDiffFiles(root, query({ scope: "commit" }))).rejects.toMatchObject({ code: "invalid_revision", status: 400 }); + await expect(repoDiffFiles(root, query({ scope: "commit", base: "no-such-branch" }))).rejects.toMatchObject({ code: "revision_not_found", status: 404 }); + await expect(repoDiffFile(root, query({ scope: "commit", base: first, head: "deadbeef", path: "a.txt" }))).rejects.toMatchObject({ code: "revision_not_found", status: 404 }); + }); +}); + +describe("repositories in an unusual state", () => { + it("reads a repository with no commit yet: nothing, then untracked, then staged", async () => { + const root = repository("unborn"); + const empty = await repoDiffFiles(root, query()); + expect(empty.repository).toEqual({ root, branch: "main", head: null, detached: false, unborn: true }); + expect(empty).toMatchObject({ files: [], total: 0, truncated: false }); + writeFileSync(join(root, "first.txt"), "hello\n"); + expect((await repoDiffFiles(root, query())).files.map((file) => [file.path, file.status])).toEqual([["first.txt", "untracked"]]); + git(root, "add", "first.txt"); + expect((await repoDiffFiles(root, query({ scope: "staged" }))).files).toEqual([{ path: "first.txt", old_path: null, status: "added", additions: 1, deletions: 0, binary: false }]); + const staged = await repoDiffFile(root, query({ scope: "staged", path: "first.txt" })); + expect(parsed(staged.patch).map((file) => [file.name, file.type])).toEqual([["first.txt", "new"]]); + await expect(repoDiffFiles(root, query({ scope: "commit", base: "HEAD" }))).rejects.toMatchObject({ code: "revision_not_found", status: 404 }); + }); + + it("reads a detached HEAD and a clean tree", async () => { + const root = repository("detached"); + writeFileSync(join(root, "a.txt"), "one\n"); + git(root, "add", "."); + git(root, "commit", "-q", "-m", "first"); + const head = git(root, "rev-parse", "HEAD").trim(); + git(root, "checkout", "-q", "--detach"); + const list = await repoDiffFiles(root, query()); + expect(list.repository).toEqual({ root, branch: null, head, detached: true, unborn: false }); + expect(list).toMatchObject({ files: [], total: 0, truncated: false }); + }); + + it("says a folder outside any repository is not one, and that a conflicted file has no unified patch", async () => { + const plain = join(scratch, "plain"); + mkdirSync(plain); + await expect(repoDiffFiles(plain, query())).rejects.toMatchObject({ code: "not_a_repository", status: 409 }); + await expect(repoDiffFile(plain, query({ path: "a.txt" }))).rejects.toMatchObject({ code: "not_a_repository", status: 409 }); + await expect(repoDiffFiles(join(scratch, "missing"), query())).rejects.toMatchObject({ code: "not_a_repository", status: 409 }); + + const root = repository("conflict"); + writeFileSync(join(root, "c.txt"), "base\n"); + git(root, "add", "."); + git(root, "commit", "-q", "-m", "base"); + git(root, "checkout", "-q", "-b", "side"); + writeFileSync(join(root, "c.txt"), "side\n"); + git(root, "commit", "-q", "-a", "-m", "side"); + git(root, "checkout", "-q", "main"); + writeFileSync(join(root, "c.txt"), "main\n"); + git(root, "commit", "-q", "-a", "-m", "main"); + expect(Bun.spawnSync(["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-C", root, "merge", "side"], { stdout: "ignore", stderr: "ignore" }).exitCode).toBe(1); + expect((await repoDiffFiles(root, query())).files).toEqual([{ path: "c.txt", old_path: null, status: "unmerged", additions: null, deletions: null, binary: null }]); + expect(await repoDiffFile(root, query({ path: "c.txt" }))).toMatchObject({ patch: null, unavailable: "unmerged", truncated: false }); + }); +}); + +describe("bounds", () => { + let root: string; + const names = Array.from({ length: 30 }, (_, index) => `f${String(index).padStart(2, "0")}.txt`); + beforeAll(() => { + root = repository("bounds"); + writeFileSync(join(root, "big.txt"), lines(400)); + git(root, "add", "."); + git(root, "commit", "-q", "-m", "first"); + writeFileSync(join(root, "big.txt"), lines(400, "changed")); + for (const name of names) writeFileSync(join(root, name), "x\n"); + }); + + it("cuts a list at the file cap and says how many there were", async () => { + const list = await repoDiffFiles(root, query(), { maxFiles: 5 }); + expect(list).toMatchObject({ total: 31, truncated: true, limits: { max_files: 5 } }); + expect(list.files.map((file) => file.path)).toEqual(["big.txt", ...names.slice(0, 4)]); + }); + + it("cuts a list at the byte cap on a whole name, with the total unknown", async () => { + const list = await repoDiffFiles(root, query(), { maxListBytes: 64 }); + expect(list).toMatchObject({ total: null, truncated: true, limits: { max_list_bytes: 64 } }); + expect(list.files.length).toBeGreaterThan(0); + expect(list.files.length).toBeLessThan(31); + for (const file of list.files) expect(["big.txt", ...names]).toContain(file.path); + }); + + it("sends no part of a patch past the cap, for a tracked and an untracked file", async () => { + const whole = await repoDiffFile(root, query({ path: "big.txt" })); + expect(parsed(whole.patch).map((file) => [file.name, file.additionLines.length])).toEqual([["big.txt", 400]]); + const capped = await repoDiffFile(root, query({ path: "big.txt" }), { maxPatchBytes: 512 }); + expect(capped).toMatchObject({ patch: null, unavailable: "too_large", truncated: true, file: { path: "big.txt", additions: 400, deletions: 400 }, limits: { max_patch_bytes: 512 } }); + writeFileSync(join(root, "f00.txt"), lines(200)); + expect(await repoDiffFile(root, query({ path: "f00.txt" }), { maxPatchBytes: 512 })).toMatchObject({ patch: null, unavailable: "too_large", truncated: true }); + // the file fits and its patch (headers, a `+` per line) does not + writeFileSync(join(root, "f01.txt"), "y\n".repeat(250)); + expect(await repoDiffFile(root, query({ path: "f01.txt" }), { maxPatchBytes: 512 })).toMatchObject({ patch: null, unavailable: "too_large", truncated: true }); + }); + + it("does not follow an untracked link out of the repository", async () => { + const secret = join(scratch, "secret.txt"); + writeFileSync(secret, "do not read\n"); + symlinkSync(secret, join(root, "link.txt")); + expect(await repoDiffFile(root, query({ path: "link.txt" }))).toMatchObject({ patch: null, unavailable: "unsupported", truncated: false }); + }); + + it("stops a git that does not answer by its deadline, and says when there is no git", async () => { + const hung = join(scratch, "hung-git"); + writeFileSync(hung, "#!/bin/sh\nexec sleep 30\n"); + chmodSync(hung, 0o755); + await expect(repoDiffFiles(root, query(), { git: hung, deadlineMs: 50 })).rejects.toMatchObject({ code: "git_timeout", status: 504 }); + await expect(repoDiffFiles(root, query(), { git: join(scratch, "no-git-here") })).rejects.toMatchObject({ code: "git_unavailable", status: 503 }); + }); + + it("runs none of the programs a repository's configuration names", async () => { + const hooked = repository("hooked"); + const marker = join(scratch, "program-ran"); + const program = join(scratch, "program.sh"); + writeFileSync(program, `#!/bin/sh\ntouch '${marker}'\n[ -f "$1" ] && cat "$1"\nexit 0\n`); + chmodSync(program, 0o755); + writeFileSync(join(hooked, ".gitattributes"), "*.txt diff=marker\n"); + writeFileSync(join(hooked, "a.txt"), "one\n"); + git(hooked, "add", "."); + git(hooked, "commit", "-q", "-m", "first"); + writeFileSync(join(hooked, "a.txt"), "two\n"); + writeFileSync(join(hooked, "b.txt"), "new\n"); + git(hooked, "config", "diff.external", program); + git(hooked, "config", "diff.marker.textconv", program); + git(hooked, "config", "core.fsmonitor", program); + rmSync(marker, { force: true }); + expect((await repoDiffFiles(hooked, query())).files.map((file) => file.path)).toEqual(["a.txt", "b.txt"]); + for (const path of ["a.txt", "b.txt"]) expect((await repoDiffFile(hooked, query({ path }))).patch).toStartWith(`diff --git a/${path} b/${path}\n`); + expect(existsSync(marker)).toBeFalse(); + }); +}); + +describe("GET /api/workspace/diff/*", () => { + let root: string; + let server: ReturnType; + let base: string; + let watchCookie: string; + const stateDir = mkdtempSync(join(tmpdir(), "herdr-repo-diff-state-")); + const owner = { authorization: "Bearer test-repo-diff" }; + beforeAll(() => { + root = repository("http"); + writeFileSync(join(root, "a.txt"), "one\n"); + git(root, "add", "."); + git(root, "commit", "-q", "-m", "first"); + writeFileSync(join(root, "a.txt"), "two\n"); + const store = new DeviceStore(stateDir); + const watchToken = store.pair(store.startPairing().code, "Watch", "watch")!.token; + // a first pairing is a device only once its cookie came back: that return is what saves it + expect(store.match(watchToken)).not.toBeNull(); + watchCookie = `herdr_web_device=${watchToken}`; + server = createServer({ port: 0, stateDir, token: "test-repo-diff", tailscaleOwner: null, machines: false }); + base = `http://127.0.0.1:${server.port}`; + }); + afterAll(() => { server.stop(); rmSync(stateDir, { recursive: true, force: true }); }); + + const files = (search: string, headers: Record = owner, method = "GET") => fetch(`${base}/api/workspace/diff/files?${search}`, { method, headers }); + const directory = () => `directory=${encodeURIComponent(root)}`; + + it("answers a named folder's list and one file's patch, never cached", async () => { + const listed = await files(directory()); + expect(listed.status).toBe(200); + expect(listed.headers.get("cache-control")).toBe("private, no-store"); + const list = await listed.json() as RepoDiffFileList; + expect(list.repository.root).toBe(root); + expect(list.files).toEqual([{ path: "a.txt", old_path: null, status: "modified", additions: 1, deletions: 1, binary: false }]); + const response = await fetch(`${base}/api/workspace/diff/file?${directory()}&path=a.txt`, { headers: owner }); + expect(response.status).toBe(200); + const answer = await response.json() as RepoDiffFilePatch; + expect(parsed(answer.patch).map((file) => [file.name, file.type])).toEqual([["a.txt", "change"]]); + }); + + it("refuses bad input in the error envelope", async () => { + const cases: [string, number, string][] = [ + ["", 400, "missing_directory"], + ["directory=relative%2Ffolder", 400, "invalid_cwd"], + [`directory=${encodeURIComponent(join(scratch, "nowhere"))}`, 400, "invalid_cwd"], + [`directory=${encodeURIComponent(scratch)}`, 409, "not_a_repository"], + [`${directory()}&scope=all`, 400, "invalid_scope"], + [`${directory()}&scope=commit&base=--output%3D%2Ftmp%2Fx`, 400, "invalid_revision"], + [`${directory()}&scope=commit&base=nope`, 404, "revision_not_found"], + ]; + for (const [search, status, code] of cases) { + const response = await files(search); + expect([search, response.status]).toEqual([search, status]); + const body = await response.json() as ApiError; + expect(body.error.code).toBe(code); + expect(typeof body.error.message).toBe("string"); + } + for (const [path, status, code] of [["..%2Fx", 400, "invalid_path"], ["", 400, "invalid_path"], ["absent.txt", 404, "not_found"]] as const) { + const response = await fetch(`${base}/api/workspace/diff/file?${directory()}&path=${path}`, { headers: owner }); + expect(response.status).toBe(status); + expect((await response.json() as ApiError).error.code).toBe(code); + } + const posted = await files(directory(), { ...owner, "x-herdr-machine": "1" }, "POST"); + expect(posted.status).toBe(400); + expect((await posted.json() as ApiError).error.code).toBe("method_not_allowed"); + }); + + it("is closed to a stranger, to another site's page and to a device that can only watch", async () => { + expect((await files(directory(), {})).status).toBe(401); + const crossSite = await files(directory(), { ...owner, "sec-fetch-site": "cross-site" }); + expect(crossSite.status).toBe(403); + expect((await crossSite.json() as ApiError).error.code).toBe("cross_site"); + expect((await fetch(`${base}/api/session`, { headers: { cookie: watchCookie } })).status).not.toBe(401); + for (const path of ["workspace/diff/files", "workspace/diff/file", "machines/pc1/workspace/diff/files", "machines/pc1/workspace/diff/file", "machines/local/workspace/diff/file"]) { + const response = await fetch(`${base}/api/${path}?${directory()}&path=a.txt`, { headers: { cookie: watchCookie } }); + expect([path, response.status]).toEqual([path, 403]); + expect((await response.json() as ApiError).error.code).toBe("read_only"); + } + }); +}); diff --git a/server/repo-diff.ts b/server/repo-diff.ts new file mode 100644 index 000000000..2dea1adb4 --- /dev/null +++ b/server/repo-diff.ts @@ -0,0 +1,323 @@ +import { closeSync, lstatSync, openSync, readSync } from "node:fs"; +import { join } from "node:path"; + +import type { RepoDiffFile, RepoDiffFileList, RepoDiffFilePatch, RepoDiffRepository, RepoDiffScope, RepoDiffStatus } from "../shared/protocol.ts"; +import { errorResponse, jsonResponse } from "./http.ts"; + +/** + * What changed in the repository a pane (or a named folder) sits in, read with git and never + * written: the list of changed files, and one file's patch at a time. The two are separate reads + * so a repository with one enormous change does not cost every other file its diff. + * + * Every git call is an argv array (no shell), with external diff drivers, textconv filters, the + * fsmonitor hook, optional locks and pathspec magic switched off, a byte cap on what is read and + * a deadline after which the process is killed. A patch is sent whole or not at all: one that + * passes its cap is reported as unavailable, never cut in the middle of a hunk. + * + * It reads what this server's user can read in that repository, which is no more than the + * terminal the same browser can already type `git diff` into. + */ + +/** A list holds at most this many files; `truncated` and `total` say when there were more. */ +export const REPO_DIFF_MAX_FILES = 2_000; +/** What one listing command may print before it is stopped (names, statuses, line counts). */ +export const REPO_DIFF_MAX_LIST_BYTES = 2 * 1024 * 1024; +/** One file's patch; a larger one is answered as `too_large` with no patch text. */ +export const REPO_DIFF_MAX_PATCH_BYTES = 1024 * 1024; +/** Unchanged lines around each change, as `git diff` prints by default. */ +export const REPO_DIFF_CONTEXT_LINES = 3; +/** How long one git process may run. */ +export const REPO_DIFF_DEADLINE_MS = 10_000; +const MAX_PATH_BYTES = 4_096; +const SNIFF_BYTES = 8_192; + +export interface RepoDiffOptions { + /** the git to run (a stand-in, in tests) */ + git?: string; + deadlineMs?: number; + maxFiles?: number; + maxListBytes?: number; + maxPatchBytes?: number; +} + +/** A refusal in the API's own words: it never carries git's stderr. */ +export class RepoDiffError extends Error { + constructor(readonly code: string, message: string, readonly status: number) { super(message); } +} + +interface GitRun { code: number | null; stdout: string; overflow: boolean } + +interface Context { + git: string; + deadlineMs: number; + maxFiles: number; + maxListBytes: number; + maxPatchBytes: number; +} + +function contextOf(options: RepoDiffOptions): Context { + return { + git: options.git ?? "git", + deadlineMs: options.deadlineMs ?? REPO_DIFF_DEADLINE_MS, + maxFiles: options.maxFiles ?? REPO_DIFF_MAX_FILES, + maxListBytes: options.maxListBytes ?? REPO_DIFF_MAX_LIST_BYTES, + maxPatchBytes: options.maxPatchBytes ?? REPO_DIFF_MAX_PATCH_BYTES, + }; +} + +function gitEnvironment(): Record { + const env: Record = {}; + for (const [name, value] of Object.entries(process.env)) { + // a repository the server itself was started inside (a hook, a test runner) is not the one asked about + if (value === undefined || name === "GIT_DIR" || name === "GIT_WORK_TREE" || name === "GIT_INDEX_FILE" || name === "GIT_EXTERNAL_DIFF" || name === "GIT_PAGER") continue; + env[name] = value; + } + return { ...env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }; +} + +/** One git process in `cwd`: stdout up to `maxBytes`, killed at the deadline or once it prints more. */ +async function runGit(context: Context, cwd: string, args: string[], maxBytes: number): Promise { + const argv = [context.git, "--no-pager", "--no-optional-locks", "--literal-pathspecs", "-c", "core.quotePath=false", "-c", "core.fsmonitor=false", "-c", "color.ui=never", "-C", cwd, ...args]; + let proc: Bun.Subprocess<"ignore", "pipe", "ignore">; + try { + proc = Bun.spawn(argv, { stdin: "ignore", stdout: "pipe", stderr: "ignore", env: gitEnvironment() }); + } catch { + throw new RepoDiffError("git_unavailable", "git could not be started on this PC", 503); + } + const reader = proc.stdout.getReader(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + proc.kill("SIGKILL"); + void reader.cancel().catch(() => undefined); + }, context.deadlineMs); + const chunks: Uint8Array[] = []; + let size = 0; + let overflow = false; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) { + overflow = true; + const keep = value.byteLength - (size - maxBytes); + if (keep > 0) chunks.push(value.subarray(0, keep)); + proc.kill("SIGKILL"); + void reader.cancel().catch(() => undefined); + break; + } + chunks.push(value); + } + const code = await proc.exited; + if (timedOut) throw new RepoDiffError("git_timeout", "git did not answer in time", 504); + return { code: overflow ? null : code, stdout: Buffer.concat(chunks).toString("utf8"), overflow }; + } finally { + clearTimeout(timer); + } +} + +async function repositoryOf(context: Context, cwd: string): Promise { + const top = await runGit(context, cwd, ["rev-parse", "--show-toplevel"], 16 * 1024); + const root = top.stdout.replace(/\r?\n$/, ""); + if (top.code !== 0 || root === "") throw new RepoDiffError("not_a_repository", "that folder is not inside a git working tree", 409); + const [branch, head] = await Promise.all([ + runGit(context, root, ["symbolic-ref", "--quiet", "--short", "HEAD"], 16 * 1024), + runGit(context, root, ["rev-parse", "--quiet", "--verify", "HEAD^{commit}"], 1024), + ]); + const oid = head.code === 0 ? head.stdout.trim() : ""; + const name = branch.code === 0 ? branch.stdout.replace(/\r?\n$/, "") : ""; + return { root, branch: name === "" ? null : name, head: oid === "" ? null : oid, detached: name === "" && oid !== "", unborn: oid === "" }; +} + +const REVISION = /^[A-Za-z0-9][A-Za-z0-9._/~^@{}-]{0,199}$/; + +async function resolveRevision(context: Context, root: string, name: string, value: string): Promise { + if (!REVISION.test(value) || value.includes("..")) throw new RepoDiffError("invalid_revision", `${name} must name one commit: a hash, a branch, a tag or an expression such as HEAD~1`, 400); + const parsed = await runGit(context, root, ["rev-parse", "--quiet", "--verify", "--end-of-options", `${value}^{commit}`], 1024); + const oid = parsed.stdout.trim(); + if (parsed.code !== 0 || !/^[0-9a-f]{40,64}$/.test(oid)) throw new RepoDiffError("revision_not_found", `${name} names no commit in this repository`, 404); + return oid; +} + +function scopeOf(value: string | null): RepoDiffScope { + if (value === null || value === "" || value === "workspace") return "workspace"; + if (value === "staged" || value === "commit") return value; + throw new RepoDiffError("invalid_scope", "scope must be workspace, staged or commit", 400); +} + +/** A path inside the repository, as git names it: relative, forward slashes, no `..`. */ +function repositoryPath(name: string, value: string): string { + const segments = value.split("/"); + if (value === "" || value.includes("\0") || value.startsWith("/") || Buffer.byteLength(value) > MAX_PATH_BYTES || segments.some((segment) => segment === ".." || segment === "." || segment === "")) { + throw new RepoDiffError("invalid_path", `${name} must be a file path relative to the repository root`, 400); + } + return value; +} + +interface Comparison { scope: RepoDiffScope; base: string | null; head: string | null; args: string[] } + +async function comparisonOf(context: Context, root: string, params: URLSearchParams): Promise { + const scope = scopeOf(params.get("scope")); + const base = params.get("base"); + const head = params.get("head"); + if (scope !== "commit") { + if (base !== null || head !== null) throw new RepoDiffError("invalid_revision", "base and head apply to scope=commit only", 400); + return { scope, base: null, head: null, args: scope === "staged" ? ["--cached"] : [] }; + } + if (base === null) throw new RepoDiffError("invalid_revision", "scope=commit needs a base revision", 400); + const [from, to] = await Promise.all([resolveRevision(context, root, "base", base), resolveRevision(context, root, "head", head ?? "HEAD")]); + return { scope, base: from, head: to, args: [from, to] }; +} + +const DIFF_FLAGS = ["--no-color", "--no-ext-diff", "--no-textconv", "--find-renames", "--ignore-submodules=dirty", "--src-prefix=a/", "--dst-prefix=b/"]; + +const STATUS: Record = { A: "added", D: "deleted", M: "modified", R: "renamed", C: "copied", T: "type_changed", U: "unmerged" }; + +/** The whole NUL-terminated fields of `-z` output: what follows the last NUL was cut short. */ +function fields(output: string): string[] { + const end = output.lastIndexOf("\0"); + return end < 0 ? [] : output.slice(0, end).split("\0"); +} + +interface Listing { files: RepoDiffFile[]; complete: boolean } + +async function changedFiles(context: Context, root: string, comparison: Comparison, pathspec: string[]): Promise { + const tail = [...comparison.args, "--", ...pathspec]; + const [names, counts, others] = await Promise.all([ + runGit(context, root, ["diff", ...DIFF_FLAGS, "--name-status", "-z", ...tail], context.maxListBytes), + runGit(context, root, ["diff", ...DIFF_FLAGS, "--numstat", "-z", ...tail], context.maxListBytes), + comparison.scope === "workspace" + ? runGit(context, root, ["ls-files", "-z", "--others", "--exclude-standard", "--", ...pathspec], context.maxListBytes) + : Promise.resolve({ code: 0, stdout: "", overflow: false }), + ]); + for (const run of [names, counts, others]) { + if (!run.overflow && run.code !== 0) throw new RepoDiffError("git_failed", "git could not compare this repository", 500); + } + const stats = new Map(); + const numbers = fields(counts.stdout); + for (let index = 0; index < numbers.length; index += 1) { + const [added, deleted, ...rest] = numbers[index]!.split("\t"); + if (added === undefined || deleted === undefined) break; + let path = rest.join("\t"); + // a rename: the counts stand alone, then the old and the new name + if (path === "") { path = numbers[index + 2] ?? ""; index += 2; } + if (path === "") break; + const binary = added === "-" || deleted === "-"; + stats.set(path, { additions: binary ? null : Number(added), deletions: binary ? null : Number(deleted), binary }); + } + const found = new Map(); + const named = fields(names.stdout); + for (let index = 0; index < named.length;) { + const letter = named[index]![0] ?? ""; + const status = STATUS[letter]; + const paired = letter === "R" || letter === "C"; + const path = named[index + (paired ? 2 : 1)]; + if (status === undefined || path === undefined) break; + // a conflict's counts are those of a combined diff, which no one is shown + const stat = status === "unmerged" ? undefined : stats.get(path); + const file: RepoDiffFile = { path, old_path: paired ? named[index + 1]! : null, status, additions: stat?.additions ?? null, deletions: stat?.deletions ?? null, binary: stat?.binary ?? null }; + // a conflicted file can be named twice; the conflict is what there is to say about it + if (found.get(path)?.status !== "unmerged") found.set(path, file); + index += paired ? 3 : 2; + } + for (const path of fields(others.stdout)) { + if (!found.has(path)) found.set(path, { path, old_path: null, status: "untracked", additions: null, deletions: null, binary: null }); + } + const files = [...found.values()].sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); + return { files, complete: !names.overflow && !others.overflow }; +} + +/** GET /api/workspace/diff/files */ +export async function repoDiffFiles(cwd: string, params: URLSearchParams, options: RepoDiffOptions = {}): Promise { + const context = contextOf(options); + const repository = await repositoryOf(context, cwd); + const comparison = await comparisonOf(context, repository.root, params); + const listing = await changedFiles(context, repository.root, comparison, []); + return { + repository, + scope: comparison.scope, + base: comparison.base, + head: comparison.head, + files: listing.files.slice(0, context.maxFiles), + total: listing.complete ? listing.files.length : null, + truncated: !listing.complete || listing.files.length > context.maxFiles, + limits: { max_files: context.maxFiles, max_list_bytes: context.maxListBytes }, + }; +} + +/** What an untracked path is on disk, without following a link out of the repository. */ +function untrackedKind(path: string, maxBytes: number): "text" | "binary" | "too_large" | "unsupported" { + let fd: number | null = null; + try { + const stat = lstatSync(path); + if (!stat.isFile()) return "unsupported"; + if (stat.size > maxBytes) return "too_large"; + fd = openSync(path, "r"); + const buffer = Buffer.alloc(SNIFF_BYTES); + return buffer.subarray(0, readSync(fd, buffer, 0, SNIFF_BYTES, 0)).includes(0) ? "binary" : "text"; + } catch { + return "unsupported"; + } finally { + if (fd !== null) closeSync(fd); + } +} + +/** GET /api/workspace/diff/file */ +export async function repoDiffFile(cwd: string, params: URLSearchParams, options: RepoDiffOptions = {}): Promise { + const context = contextOf(options); + const path = repositoryPath("path", params.get("path") ?? ""); + const oldParam = params.get("old_path"); + const oldPath = oldParam === null || oldParam === "" ? null : repositoryPath("old_path", oldParam); + const repository = await repositoryOf(context, cwd); + const comparison = await comparisonOf(context, repository.root, params); + const pathspec = oldPath !== null && oldPath !== path ? [oldPath, path] : [path]; + const listing = await changedFiles(context, repository.root, comparison, pathspec); + const file = listing.files.find((candidate) => candidate.path === path); + if (file === undefined) throw new RepoDiffError("not_found", "nothing changed at that path in this comparison", 404); + if (oldPath !== null && file.old_path !== oldPath) throw new RepoDiffError("invalid_path", "old_path no longer belongs to this file's change; refresh the list", 400); + + const answer = (patch: string | null, unavailable: RepoDiffFilePatch["unavailable"], described: RepoDiffFile = file): RepoDiffFilePatch => ({ + repository, + scope: comparison.scope, + base: comparison.base, + head: comparison.head, + file: described, + patch, + unavailable, + truncated: unavailable === "too_large", + limits: { max_patch_bytes: context.maxPatchBytes, context_lines: REPO_DIFF_CONTEXT_LINES }, + }); + + if (file.status === "unmerged") return answer(null, "unmerged"); + if (file.binary === true) return answer(null, "binary"); + + let run: GitRun; + if (file.status === "untracked") { + const kind = untrackedKind(join(repository.root, path), context.maxPatchBytes); + if (kind === "binary") return answer(null, "binary", { ...file, binary: true }); + if (kind !== "text") return answer(null, kind); + // an untracked file has no index entry to compare with: it is shown as a new file, whole + run = await runGit(context, repository.root, ["diff", "--no-index", ...DIFF_FLAGS, `-U${REPO_DIFF_CONTEXT_LINES}`, "--", "/dev/null", path], context.maxPatchBytes); + if (!run.overflow && run.code !== 1 && run.code !== 0) throw new RepoDiffError("git_failed", "git could not read that file", 500); + } else { + run = await runGit(context, repository.root, ["diff", ...DIFF_FLAGS, `-U${REPO_DIFF_CONTEXT_LINES}`, ...comparison.args, "--", ...pathspec], context.maxPatchBytes); + if (!run.overflow && run.code !== 0) throw new RepoDiffError("git_failed", "git could not compare that file", 500); + } + if (run.overflow) return answer(null, "too_large"); + // the file changed between the listing and this read, or git printed a form no patch reader takes + if (run.stdout === "") throw new RepoDiffError("not_found", "nothing changed at that path in this comparison", 404); + if (!run.stdout.startsWith("diff --git ")) return answer(null, "unsupported"); + return answer(run.stdout, null, file.status === "untracked" ? { ...file, binary: false } : file); +} + +/** The HTTP answer of either route, with every refusal in the `{ error: { code, message } }` envelope. */ +export async function repoDiffResponse(kind: "files" | "file", cwd: string, params: URLSearchParams, options: RepoDiffOptions = {}): Promise { + try { + const body = kind === "files" ? await repoDiffFiles(cwd, params, options) : await repoDiffFile(cwd, params, options); + return jsonResponse(body, 200, { "cache-control": "private, no-store" }); + } catch (error) { + if (error instanceof RepoDiffError) return jsonResponse({ error: { code: error.code, message: error.message } }, error.status); + return errorResponse(error); + } +} diff --git a/server/retention-budget.ts b/server/retention-budget.ts new file mode 100644 index 000000000..ba364538c --- /dev/null +++ b/server/retention-budget.ts @@ -0,0 +1,18 @@ +/** Conservative retained-content estimate, not a claim about engine RSS. Stops at the budget. */ +export function withinRetentionBudget(value: unknown, limit: number): boolean { + let bytes = 0; + const seen = new WeakSet(); + function visit(v: unknown, depth: number): boolean { + if (depth > 100) return false; + if (typeof v === "string") bytes += 2 * v.length; + else if (v && typeof v === "object") { + if (seen.has(v)) return true; + seen.add(v); bytes += 64; + if (bytes > limit) return false; + if (v instanceof Map) { for (const [key, item] of v) if (!visit(key, depth + 1) || !visit(item, depth + 1)) return false; } + else { for (const [key, item] of Object.entries(v)) if (!visit(key, depth + 1) || !visit(item, depth + 1)) return false; } + } else bytes += 8; + return bytes <= limit; + } + return visit(value, 0); +} diff --git a/server/shell-agent.test.ts b/server/shell-agent.test.ts index 6f33d7713..dc4bff8fa 100644 --- a/server/shell-agent.test.ts +++ b/server/shell-agent.test.ts @@ -1,7 +1,7 @@ -import { expect, it } from "bun:test"; +import { expect, it, spyOn } from "bun:test"; import { isGjcProcess } from "./gjc-runtime.ts"; import { paneShell, shellAgentExecutable, shellCommandLine } from "./shell-agent.ts"; -import { descendantArgv, parseProcessTable, windowsArgv, windowsProcessTable } from "./windows-processes.ts"; +import { descendantArgv, descendantProcesses, parseProcessTable, windowsArgv, windowsProcessTable } from "./windows-processes.ts"; it("tells a pane's shell from the program herdr reports in front", () => { expect(paneShell("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")).toBe("powershell"); @@ -44,6 +44,8 @@ it("finds what runs under a pane's shell in the process table", () => { ["C:\\Program Files\\nodejs\\node.exe", "C:\\x y\\tool.js", "--flag"], ]); expect(under.some(isGjcProcess)).toBe(true); + // the same walk, with each process's number + expect(descendantProcesses(table, 41948).map((entry) => entry.pid)).toEqual([12156, 700]); // another pane's shell has no gjc under it, though one runs elsewhere on the PC expect(descendantArgv(table, 26540).length).toBe(3); expect(descendantArgv(table, 4242)).toEqual([]); @@ -61,6 +63,14 @@ it("gives up on a process-table query that stalls, and kills it", async () => { expect(await windowsProcessTable(5000, ["printf", '[{"ProcessId":2,"ParentProcessId":1,"ExecutablePath":null,"CommandLine":"x"}]'])).toEqual([{ pid: 2, parent: 1, path: null, commandLine: "x" }]); }); -it("offers on Windows only the shell-started agents known to start there", () => { - expect(shellAgentExecutable("omo", "win32")).toBeNull(); +it("offers OmO on Windows when its launcher is on PATH", () => { + const which = spyOn(Bun, "which").mockReturnValue("C:\\tools\\omo.exe"); + try { + expect(shellAgentExecutable("omo", "win32")).toBe("C:\\tools\\omo.exe"); + expect(which).toHaveBeenCalledWith("omo", { PATH: process.env["PATH"] ?? "" }); + which.mockReturnValue(null); + expect(shellAgentExecutable("omo", "win32")).toBeNull(); + } finally { + which.mockRestore(); + } }); diff --git a/server/shell-agent.ts b/server/shell-agent.ts index 5755668e4..36a6fc087 100644 --- a/server/shell-agent.ts +++ b/server/shell-agent.ts @@ -11,7 +11,7 @@ */ import { herdrRpc } from "./herdr/client.ts"; import { isGjcProcess } from "./gjc-runtime.ts"; -import { isOmoProcess } from "./omo.ts"; +import { isOmoProcess, omoProcessIdentity } from "./omo.ts"; import { psQuote } from "./powershell.ts"; import { descendantArgv, windowsProcessTable } from "./windows-processes.ts"; @@ -21,7 +21,7 @@ export const SHELL_AGENTS: Record boolean> gjc: isGjcProcess, }; /** The shell-started agents known to start on Windows; the others are not offered there. */ -const WINDOWS_SHELL_AGENTS = new Set(["gjc"]); +const WINDOWS_SHELL_AGENTS = new Set(["omo", "gjc"]); export function isShellAgentKind(kind: string): boolean { return Object.hasOwn(SHELL_AGENTS, kind); } @@ -55,7 +55,7 @@ export function shellCommandLine(shell: PaneShell, executable: string, args: rea return [executable, ...args].map(quote).join(" "); } -interface ProcessInfo { process_info?: { shell_pid?: number; foreground_processes?: { argv?: string[] }[] } } +interface ProcessInfo { process_info?: { shell_pid?: number; foreground_processes?: { pid: number; argv?: string[]; cwd?: string }[] } } const processInfo = (paneId: string) => herdrRpc("pane.process_info", { pane_id: paneId }).catch(() => null); /** @@ -85,7 +85,7 @@ export async function startShellAgent(kind: string, paneId: string, args: string const deadline = Date.now() + (options.timeoutMs ?? 60_000); while (Date.now() < deadline) { const info = await processInfo(paneId); - if (info?.process_info?.foreground_processes?.some((process) => isProcess(process.argv ?? []))) return; + if (info?.process_info?.foreground_processes?.some((entry) => kind === "omo" ? omoProcessIdentity(entry, "", process.env["HOME"] ?? "") !== null : isProcess(entry.argv ?? []))) return; const shellPid = info?.process_info?.shell_pid; // the table query never outlasts what is left of the start's own time if (windows && typeof shellPid === "number" && descendantArgv(await windowsProcessTable(Math.max(500, Math.min(10_000, deadline - Date.now()))), shellPid).some(isProcess)) return; diff --git a/server/ssh.ts b/server/ssh.ts index c4cbd9649..95bf34d15 100644 --- a/server/ssh.ts +++ b/server/ssh.ts @@ -162,7 +162,7 @@ export class SshConnection { let done = 0; try { for await (const chunk of chunksOf(Bun.file(stream.path).stream())) { - sink.write(chunk); await sink.flush(); + await sink.write(chunk); await sink.flush(); done += chunk.length; stream.onProgress?.(done); } } finally { try { await sink.end(); } catch {} } diff --git a/server/start-screen.test.ts b/server/start-screen.test.ts new file mode 100644 index 000000000..78aee68a4 --- /dev/null +++ b/server/start-screen.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test"; + +import { readsStartScreen, startScreenMetadata } from "./start-screen.ts"; + +const CLAUDE = [ + " ▐▛███▛█ Claude Code v2.1.296", + "▝▜██████▀ Haiku 5.5 · Claude Max", + " ▝▝ ▝▝ ~/workspace/test-project", + "────────────────────────────────────────", + "❯ Try \"fix lint errors\"", + "────────────────────────────────────────", + " ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents", +].join("\n"); + +const CODEX = [ + " >_ OpenAI Codex (v0.162.1)", + " /tmp/test-project", + " permissions: YOLO mode", + " /model - choose what model and reasoning effort to use", + "› Ask Codex to do anything", + " GPT-6-Luna medium fast · /tmp/test-project", + " ← for agents · ? for shortcuts", +].join("\n"); + +const GJC = [ + "│ [ ⬢ Anthropic Opus 5.5 ] │ ─────────────── │", + "╰──────────────────────────────────────────────────────────┴─────────────────╯", + " ⬢ opus-5.5 · ◉ xhigh · 1.5% / ⑂ main / 📁 …/test-project ───── (sub) / v0.17.7", + "│ > Type your message... ⌥Q: Queue (busy) · ⇧⇥: Thinking │", +].join("\n"); + +describe("startScreenMetadata", () => { + it("reads Claude Code's model under its title, as the id its transcript records", () => { + expect(startScreenMetadata("claude-transcript", CLAUDE)).toEqual({ model: "claude-haiku-5-5", reasoning_effort: null }); + expect(startScreenMetadata("claude-transcript", CLAUDE.replace("Haiku 5.5", "Sonnet 5"))).toEqual({ model: "claude-sonnet-5", reasoning_effort: null }); + }); + + it("names no Claude model it cannot give an id for certain", () => { + expect(startScreenMetadata("claude-transcript", CLAUDE.replace("Haiku 5.5", "Opus 5.5 (1M context)")).model).toBeNull(); + expect(startScreenMetadata("claude-transcript", CLAUDE.replace("Haiku 5.5", "Default")).model).toBeNull(); + // the folder question stands where the title would: nothing says the model yet + expect(startScreenMetadata("claude-transcript", "Accessing workspace\nIs this a project you created or one you trust?").model).toBeNull(); + }); + + it("reads Codex's model and effort from the line under its composer", () => { + expect(startScreenMetadata("codex-transcript", CODEX)).toEqual({ model: "gpt-6-luna", reasoning_effort: "medium" }); + expect(startScreenMetadata("codex-transcript", CODEX.replace("GPT-6-Luna medium fast", "GPT-5.6 xhigh"))).toEqual({ model: "gpt-5.6", reasoning_effort: "xhigh" }); + }); + + it("reads nothing of a Codex screen without its header or its status line", () => { + expect(startScreenMetadata("codex-transcript", " GPT-6-Luna medium fast · /tmp/test-project")).toEqual({ model: null, reasoning_effort: null }); + expect(startScreenMetadata("codex-transcript", " >_ OpenAI Codex (v0.162.1)\n Trust this folder?\n› 1. Trust and continue")).toEqual({ model: null, reasoning_effort: null }); + }); + + it("reads gjc's status line, keeping a model it has no id for as gjc wrote it", () => { + expect(startScreenMetadata("gjc-transcript", GJC)).toEqual({ model: "claude-opus-5-5", reasoning_effort: "xhigh" }); + expect(startScreenMetadata("gjc-transcript", GJC.replace("opus-5.5", "kimi-k3"))).toEqual({ model: "kimi-k3", reasoning_effort: "xhigh" }); + }); + + it("reads only the agents whose screen it knows", () => { + expect(readsStartScreen("codex-transcript")).toBeTrue(); + expect(readsStartScreen("opencode-transcript")).toBeFalse(); + expect(startScreenMetadata("omo-transcript", CODEX)).toEqual({ model: null, reasoning_effort: null }); + expect(startScreenMetadata("claude-transcript", CODEX)).toEqual({ model: null, reasoning_effort: null }); + }); +}); diff --git a/server/start-screen.ts b/server/start-screen.ts new file mode 100644 index 000000000..7f90fe009 --- /dev/null +++ b/server/start-screen.ts @@ -0,0 +1,62 @@ +import type { ConversationMetadata, ConversationResponse } from "../shared/protocol.ts"; + +/** + * The model and reasoning effort an agent's own screen states before its first message, when no + * transcript records them yet. Only a line in the agent's own layout is read, and a model is + * given the id its transcript will record only where the screen's name maps to it by the + * vendor's regular rule (src/lib/modelName.ts); anything else stays unknown rather than guessed. + * Captured live 2026-10-14: Claude Code 2.1.296, Codex 0.162.1, gjc 0.17.7. + */ +type Screen = Pick; +const NOTHING: Screen = { model: null, reasoning_effort: null }; + +const CLAUDE_FAMILY = "opus|sonnet|haiku|fable|mythos"; +const VERSION = "[1-9]\\d?"; +const EFFORT = "none|minimal|low|medium|high|xhigh|max"; + +function lastMatch(lines: string[], pattern: RegExp): RegExpExecArray | null { + for (let index = lines.length - 1; index >= 0; index -= 1) { + const match = pattern.exec(lines[index]!); + if (match) return match; + } + return null; +} + +/** `▝▜██████▀ Haiku 5.5 · Claude Max`, the line under `Claude Code v…`. */ +function claudeScreen(lines: string[]): Screen { + const title = lastMatch(lines, /Claude Code v\d/u); + if (!title) return NOTHING; + const named = new RegExp(`(?:^|\\s)(${CLAUDE_FAMILY}) (${VERSION})(?:\\.(0|${VERSION}))? · `, "iu").exec(lines[lines.indexOf(title.input) + 1] ?? ""); + if (!named) return NOTHING; + return { model: `claude-${named[1]!.toLowerCase()}-${named[2]}${named[3] === undefined ? "" : `-${named[3]}`}`, reasoning_effort: null }; +} + +/** ` GPT-6-Luna medium fast · /folder`, the status line under the composer. */ +function codexScreen(lines: string[]): Screen { + if (!lines.some((line) => line.includes("OpenAI Codex (v"))) return NOTHING; + const found = lastMatch(lines, new RegExp(`^\\s*(GPT-[\\w.-]+) (${EFFORT})(?: \\w+)? · `, "u")); + return found ? { model: found[1]!.toLowerCase(), reasoning_effort: found[2]! } : NOTHING; +} + +/** ` ⬢ opus-5.5 · ◉ xhigh · 1.5% / …`, the status line over the composer. */ +function gjcScreen(lines: string[]): Screen { + const found = lastMatch(lines, new RegExp(`⬢ (\\S+) · ◉ (${EFFORT})(?: |$)`, "u")); + if (!found) return NOTHING; + const claude = new RegExp(`^(${CLAUDE_FAMILY})-(${VERSION})(?:\\.(0|${VERSION}))?$`, "u").exec(found[1]!); + const model = claude ? `claude-${claude[1]}-${claude[2]}${claude[3] === undefined ? "" : `-${claude[3]}`}` : found[1]!; + return { model, reasoning_effort: found[2]! }; +} + +const SCREENS: Partial Screen>> = { + "claude-transcript": claudeScreen, + "codex-transcript": codexScreen, + "gjc-transcript": gjcScreen, +}; + +export function readsStartScreen(source: ConversationResponse["source"]): boolean { + return SCREENS[source] !== undefined; +} + +export function startScreenMetadata(source: ConversationResponse["source"], screen: string): Screen { + return SCREENS[source]?.(screen.split("\n")) ?? NOTHING; +} diff --git a/server/static-encoding.test.ts b/server/static-encoding.test.ts index 9568931a1..fd6df35a1 100644 --- a/server/static-encoding.test.ts +++ b/server/static-encoding.test.ts @@ -88,3 +88,21 @@ test("the production gzip wrapper preserves the representation already negotiate const gzip = await compressResponse(gzipRequest, await serve("/assets/large.js", "gzip")); expect(gzip?.headers.get("content-encoding")).toBe("gzip"); })); + +test("answers a file the build does not have with a 404, and any other path with the shell", () => fixture(async serve => { + // a tab of the build before an update asks for its own chunk: the shell under a script's URL + // fails as a module anyway, and a service worker would keep it there + for (const path of ["/assets/katex-old.js", "/assets/nested/", "/assets/", "/icons/icon-512-old.png"]) { + const missing = await serve(path, "gzip"); + expect(missing.status).toBe(404); + expect(missing.headers.get("content-type")).toBeNull(); + expect(missing.headers.get("cache-control")).toBe("no-cache"); + expect(await missing.text()).toBe(""); + } + for (const path of ["/", "/settings", "/assetsx/thing.js"]) { + const shell = await serve(path); + expect(shell.status).toBe(200); + expect(shell.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(await shell.text()).toBe("fixture shell"); + } +})); diff --git a/server/static.ts b/server/static.ts index 8682a51c9..bb6da8b88 100644 --- a/server/static.ts +++ b/server/static.ts @@ -201,6 +201,11 @@ export async function serveStatic(pathname: string, acceptEncoding?: string | nu }); } } + // a missing file is not a page: the shell under a bundle's URL fails as a script anyway, and a + // service worker would keep it there. An old tab asks for its own build's chunks after an update. + if (pathname.startsWith("/assets/") || pathname.startsWith("/icons/")) { + return new Response(null, { status: 404, headers: { "cache-control": REVALIDATE, ...cspHeader() } }); + } return new Response(await body(Bun.file(indexPath), "shell"), { headers: { "content-type": "text/html; charset=utf-8", "cache-control": REVALIDATE, ...cspHeader() }, }); diff --git a/server/submit.contract.test.ts b/server/submit.contract.test.ts index 6be34503d..b9c677fe6 100644 --- a/server/submit.contract.test.ts +++ b/server/submit.contract.test.ts @@ -1,10 +1,11 @@ import { afterAll, beforeAll, describe, expect, it, spyOn } from "bun:test"; -import { chmodSync, copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { createServer, SUBMIT_DEADLINE_MS, SUBMIT_DELAY_MS } from "./index.ts"; import { herdrRpc } from "./herdr/client.ts"; import * as herdr from "./herdr/client.ts"; +import { PtySession } from "./pty/session.ts"; /** * Contract test for the composer's "submit" frame, against the real herdr server. @@ -178,6 +179,53 @@ afterAll(async () => { }); describe("WebSocket submit", () => { + it("rejects a held-copy resend combined with Send anyway before reserving or replaying its id", async () => { + const socket = await Socket.connect(); + const from = chunks(shell).length; + const frame = { type: "submit", pane_id: shell.pane, text: "combined-copy-541", payload: "combined-copy-541", resend_id: "combined-contract-541" }; + try { + socket.send({ ...frame, id: 550, send_anyway: true }); + expect(await socket.result(550)).toMatchObject({ ok: false, code: "invalid_delivery" }); + expect(typed(shell, from)).toBe(""); + socket.send({ ...frame, id: 551 }); + expect(await socket.result(551)).toMatchObject({ ok: true }); + socket.send({ ...frame, id: 552, send_anyway: true }); + expect(await socket.result(552)).toMatchObject({ ok: false, code: "invalid_delivery" }); + const barrier = new Promise((resolve, reject) => { + const watcher = watch(dirname(shell.log), () => { + if (typed(shell, from).includes("combination-barrier541")) { clearTimeout(timer); watcher.close(); resolve(); } + }); + const timer = setTimeout(() => { watcher.close(); reject(new Error("combination recorder barrier timed out")); }, 5000); + }); + socket.send({ type: "submit", id: 553, pane_id: shell.pane, text: "combination-barrier541", payload: "combination-barrier541" }); + expect(await socket.result(553)).toMatchObject({ ok: true }); + await barrier; + expect(typed(shell, from).split("combined-copy-541").length - 1).toBe(1); + } finally { socket.close(); } + }, 30_000); + + it("regression: sends one held-copy resend across two connections", async () => { + const first = await Socket.connect(), second = await Socket.connect(); + const from = chunks(shell).length; + try { + const frame = { type: "submit", pane_id: shell.pane, text: "held-copy-541", payload: "held-copy-541", resend_id: "held-copy-regression-541" }; + first.send({ ...frame, id: 541 }); + second.send({ ...frame, id: 542 }); + expect(await first.result(541)).toMatchObject({ ok: true }); + expect(await second.result(542)).toMatchObject({ ok: true }); + // The recorder processing a later serialized message proves all preceding bytes arrived. + const barrier = new Promise((resolve, reject) => { + const timer = setTimeout(() => { watcher.close(); reject(new Error("recorder barrier timed out")); }, 5000); + const watcher = watch(dirname(shell.log), () => { + if (typed(shell, from).includes("barrier541")) { clearTimeout(timer); watcher.close(); resolve(); } + }); + }); + second.send({ type: "submit", id: 543, pane_id: shell.pane, text: "barrier541", payload: "barrier541" }); + expect(await second.result(543)).toMatchObject({ ok: true }); + await barrier; + expect(typed(shell, from).split("held-copy-541").length - 1).toBe(1); + } finally { first.close(); second.close(); } + }, 30_000); for (const failure of ["read", "menu", "clear", "keys", "send"] as const) { it(`reports a partial outcome for a ${failure} failure after clearing starts`, async () => { const socket = await Socket.connect(); @@ -477,6 +525,177 @@ describe("WebSocket submit", () => { } }, 30_000); + it("shows the box of a refusal that names no draft, then sends anyway once asked, through agent.prompt", async () => { + const socket = await Socket.connect(); + const screen = mockClaudeInput(agent.pane, claudeInputScreen("! ls")); + const prompt = spyOn(herdr, "agentPrompt").mockResolvedValue(undefined); + const sendText = spyOn(herdr, "paneSendText").mockResolvedValue(undefined); + const sendKeys = spyOn(herdr, "paneSendKeys").mockResolvedValue(undefined); + try { + socket.send({ type: "submit", id: 90, pane_id: agent.pane, text: "chat message", payload: "chat message" }); + const refused = await socket.result(90); + expect(refused).toMatchObject({ ok: false, code: "input_draft", box: "! ls" }); + expect(refused.draft).toBeUndefined(); + expect(prompt).not.toHaveBeenCalled(); + socket.send({ type: "submit", id: 91, pane_id: agent.pane, text: "chat message", payload: "chat message", send_anyway: true }); + expect(await socket.result(91)).toMatchObject({ ok: true, pane_id: agent.pane }); + expect(prompt).toHaveBeenCalledWith(agent.pane, "chat message"); + expect(sendText).not.toHaveBeenCalled(); + expect(sendKeys).not.toHaveBeenCalled(); + // the refusal is used up: a second send anyway is refused, typing nothing + socket.send({ type: "submit", id: 92, pane_id: agent.pane, text: "chat message", payload: "chat message", send_anyway: true }); + expect(await socket.result(92)).toMatchObject({ ok: false, code: "pending_target_changed" }); + expect(prompt).toHaveBeenCalledTimes(1); + } finally { + sendKeys.mockRestore(); sendText.mockRestore(); prompt.mockRestore(); + screen.scroll.mockRestore(); screen.read.mockRestore(); socket.close(); + } + }, 30_000); + + it("refuses send anyway without a refusal that showed the box, or after one that named a draft", async () => { + const socket = await Socket.connect(); + const screen = mockClaudeInput(agent.pane, claudeInputScreen("❯ named draft")); + const prompt = spyOn(herdr, "agentPrompt").mockResolvedValue(undefined); + const sendText = spyOn(herdr, "paneSendText").mockResolvedValue(undefined); + try { + socket.send({ type: "submit", id: 93, pane_id: agent.pane, text: "chat", payload: "chat", send_anyway: true }); + expect(await socket.result(93)).toMatchObject({ ok: false, code: "pending_target_changed" }); + socket.send({ type: "submit", id: 94, pane_id: agent.pane, text: "chat", payload: "chat" }); + const named = await socket.result(94); + expect(named).toMatchObject({ ok: false, code: "input_draft", draft: "named draft" }); + expect(named.box).toBeUndefined(); + socket.send({ type: "submit", id: 95, pane_id: agent.pane, text: "chat", payload: "chat", send_anyway: true }); + expect(await socket.result(95)).toMatchObject({ ok: false, code: "pending_target_changed" }); + expect(prompt).not.toHaveBeenCalled(); + expect(sendText).not.toHaveBeenCalled(); + } finally { + sendText.mockRestore(); prompt.mockRestore(); + screen.scroll.mockRestore(); screen.read.mockRestore(); socket.close(); + } + }, 30_000); + + it("refuses send anyway once Claude's session changed after the refusal", async () => { + const socket = await Socket.connect(); + const screen = mockClaudeInput(agent.pane, claudeInputScreen("! ls")); + const original = herdr.paneGet; + let replaced = false; + const get = spyOn(herdr, "paneGet").mockImplementation(async (paneId, socketPath) => { + const pane = await original(paneId, socketPath); + return paneId === agent.pane && replaced ? { ...pane, agent_session: { kind: "session_id", source: "cli", value: "replacement-session", agent: "claude" } } : pane; + }); + const prompt = spyOn(herdr, "agentPrompt").mockResolvedValue(undefined); + try { + socket.send({ type: "submit", id: 96, pane_id: agent.pane, text: "chat", payload: "chat" }); + expect(await socket.result(96)).toMatchObject({ ok: false, code: "input_draft", box: "! ls" }); + replaced = true; + socket.send({ type: "submit", id: 97, pane_id: agent.pane, text: "chat", payload: "chat", send_anyway: true }); + expect(await socket.result(97)).toMatchObject({ ok: false, code: "pending_target_changed" }); + expect(prompt).not.toHaveBeenCalled(); + } finally { + prompt.mockRestore(); get.mockRestore(); + screen.scroll.mockRestore(); screen.read.mockRestore(); socket.close(); + } + }, 30_000); + + it("refuses send anyway over a box that changed since the refusal, and shows the new one", async () => { + const socket = await Socket.connect(); + let shown = claudeInputScreen("! ls"); + const screen = mockClaudeInput(agent.pane, () => shown); + const prompt = spyOn(herdr, "agentPrompt").mockResolvedValue(undefined); + try { + socket.send({ type: "submit", id: 102, pane_id: agent.pane, text: "chat", payload: "chat" }); + expect(await socket.result(102)).toMatchObject({ ok: false, code: "input_draft", box: "! ls" }); + shown = claudeInputScreen("! rm -rf build"); + socket.send({ type: "submit", id: 103, pane_id: agent.pane, text: "chat", payload: "chat", send_anyway: true }); + expect(await socket.result(103)).toMatchObject({ ok: false, code: "input_draft", box: "! rm -rf build" }); + expect(prompt).not.toHaveBeenCalled(); + // the box the user now saw may be sent over + socket.send({ type: "submit", id: 104, pane_id: agent.pane, text: "chat", payload: "chat", send_anyway: true }); + expect(await socket.result(104)).toMatchObject({ ok: true }); + expect(prompt).toHaveBeenCalledTimes(1); + } finally { + prompt.mockRestore(); + screen.scroll.mockRestore(); screen.read.mockRestore(); socket.close(); + } + }, 30_000); + + it("offers no send anyway over a box it cannot read whole, and refuses one", async () => { + const socket = await Socket.connect(); + // a draft taller than the pane: only the bottom rule is on screen + const screen = mockClaudeInput(agent.pane, [" more of a tall draft", " its last line", claudeRule, " footer"].join("\r\n")); + const prompt = spyOn(herdr, "agentPrompt").mockResolvedValue(undefined); + try { + socket.send({ type: "submit", id: 109, pane_id: agent.pane, text: "chat", payload: "chat" }); + const refused = await socket.result(109); + expect(refused).toMatchObject({ ok: false, code: "input_draft" }); + expect(refused.box).toBeUndefined(); + socket.send({ type: "submit", id: 110, pane_id: agent.pane, text: "chat", payload: "chat", send_anyway: true }); + expect(await socket.result(110)).toMatchObject({ ok: false, code: "pending_target_changed" }); + expect(prompt).not.toHaveBeenCalled(); + } finally { + prompt.mockRestore(); + screen.scroll.mockRestore(); screen.read.mockRestore(); socket.close(); + } + }, 30_000); + + it("refuses send anyway when the box changed past what the note shows", async () => { + const socket = await Socket.connect(); + let shown = claudeInputScreen("! " + "x".repeat(220) + " OLD"); + const screen = mockClaudeInput(agent.pane, () => shown); + const prompt = spyOn(herdr, "agentPrompt").mockResolvedValue(undefined); + try { + socket.send({ type: "submit", id: 105, pane_id: agent.pane, text: "chat", payload: "chat" }); + const refused = await socket.result(105); + expect(refused).toMatchObject({ ok: false, code: "input_draft" }); + // the note shows the box cut at 200 characters; the server keeps it whole + expect(refused.box).toBe(`! ${"x".repeat(198)}…`); + shown = claudeInputScreen("! " + "x".repeat(220) + " NEW"); + socket.send({ type: "submit", id: 106, pane_id: agent.pane, text: "chat", payload: "chat", send_anyway: true }); + expect(await socket.result(106)).toMatchObject({ ok: false, code: "input_draft" }); + expect(prompt).not.toHaveBeenCalled(); + } finally { + prompt.mockRestore(); + screen.scroll.mockRestore(); screen.read.mockRestore(); socket.close(); + } + }, 30_000); + + it("refuses send anyway when Claude's session is replaced while the box is read", async () => { + const socket = await Socket.connect(); + let replaceOnRead = false; + let replaced = false; + const original = herdr.paneGet; + const get = spyOn(herdr, "paneGet").mockImplementation(async (paneId, socketPath) => { + const pane = await original(paneId, socketPath); + return paneId === agent.pane && replaced ? { ...pane, agent_session: { kind: "session_id", source: "cli", value: "replacement-after-check", agent: "claude" } } : pane; + }); + const screen = mockClaudeInput(agent.pane, () => { if (replaceOnRead) replaced = true; return claudeInputScreen("! ls"); }); + const prompt = spyOn(herdr, "agentPrompt").mockResolvedValue(undefined); + try { + socket.send({ type: "submit", id: 107, pane_id: agent.pane, text: "chat", payload: "chat" }); + expect(await socket.result(107)).toMatchObject({ ok: false, code: "input_draft", box: "! ls" }); + replaceOnRead = true; + socket.send({ type: "submit", id: 108, pane_id: agent.pane, text: "chat", payload: "chat", send_anyway: true }); + expect(await socket.result(108)).toMatchObject({ ok: false, code: "pending_target_changed" }); + expect(replaced).toBe(true); + expect(prompt).not.toHaveBeenCalled(); + } finally { + prompt.mockRestore(); get.mockRestore(); + screen.scroll.mockRestore(); screen.read.mockRestore(); socket.close(); + } + }, 30_000); + + it("sends anyway only a chat message sent at once, without clearing a draft", async () => { + const socket = await Socket.connect(); + try { + for (const [id, extra] of [[98, { delivery: "queue" }], [99, { typed: true }], [100, { clear_draft: "x" }], [101, { send_anyway: false }]] as const) { + socket.send({ type: "submit", id, pane_id: agent.pane, text: "chat", payload: "chat", send_anyway: true, ...extra }); + expect(await socket.result(id)).toMatchObject({ ok: false, code: "invalid_delivery" }); + } + } finally { + socket.close(); + } + }, 30_000); + it("clears the draft a refusal named, once asked to, and sends when the box reads empty", async () => { const socket = await Socket.connect(); let shown = claudeInputScreen("❯ ㅔ"); @@ -997,17 +1216,67 @@ describe("WebSocket submit", () => { it("sends a message typed right after a Stop only once the Stop reached the pane", async () => { const socket = await Socket.connect(); + const originalWrite = PtySession.prototype.write; + const originalSendText = herdr.paneSendText; + let releaseStop: (() => void) | undefined; + // A busy sidecar can still hold a pty ESC when the later RPC paste reaches herdr. + // Force that interleaving without a clock: a pty ESC goes out only after the paste. + const write = spyOn(PtySession.prototype, "write").mockImplementation(function (this: PtySession, text) { + if (text === "\u001b") { + releaseStop = () => { originalWrite.call(this, text); }; + return true; + } + return originalWrite.call(this, text); + }); + const sendText = spyOn(herdr, "paneSendText").mockImplementation(async (...args) => { + const result = await originalSendText(...args); + if (args[0] === shell.pane && args[1] === "after") releaseStop?.(); + return result; + }); try { - socket.send({ type: "attach", pane_id: shell.pane, cols: 100, rows: 30 }); - await socket.waitFor((message) => message.type === "pty-data" && message.pane_id === shell.pane); - await Bun.sleep(300); + await attached(socket, shell.pane); const from = chunks(shell).length; - socket.send({ type: "input", pane_id: shell.pane, text: "\u001b" }); + socket.send({ type: "input", pane_id: shell.pane, text: "\u001b", stop: true }); socket.send({ type: "submit", id: 8, pane_id: shell.pane, text: "after", payload: "after" }); - await socket.result(8); + expect(await socket.result(8)).toMatchObject({ ok: true }); await received(shell, from, 1); expect(typed(shell, from)).toBe("\u001bafter\r"); } finally { + write.mockRestore(); + sendText.mockRestore(); + socket.close(); + } + }, 30_000); + + it("keeps a terminal ESC in order with the typing around it", async () => { + const socket = await Socket.connect(); + await attached(socket, shell.pane); + const originalWrite = PtySession.prototype.write; + const originalSendText = herdr.paneSendText; + const held: Array<() => void> = []; + const flush = () => { for (const write of held.splice(0)) write(); }; + // Hold the pty's writes as a busy sidecar would: they go out once all three frames were + // written, or once something sent through herdr meanwhile reached the pane ahead of them. + const write = spyOn(PtySession.prototype, "write").mockImplementation(function (this: PtySession, text) { + held.push(() => { originalWrite.call(this, text); }); + if (held.length === 3) flush(); + return true; + }); + const sendText = spyOn(herdr, "paneSendText").mockImplementation(async (...args) => { + const result = await originalSendText(...args); + if (args[0] === shell.pane) flush(); + return result; + }); + try { + const from = chunks(shell).length; + socket.send({ type: "input", pane_id: shell.pane, text: "x" }); + socket.send({ type: "input", pane_id: shell.pane, text: "\u001b" }); + socket.send({ type: "input", pane_id: shell.pane, text: "y\r" }); + await received(shell, from, 1); + expect(typed(shell, from)).toBe("x\u001by\r"); + } finally { + write.mockRestore(); + sendText.mockRestore(); socket.close(); } }, 30_000); @@ -1326,6 +1595,129 @@ describe("typing without terminal attach, at the edges", () => { } }, 30_000); + for (const [label, secret] of [["emoji", "\u{1F642}"], ["accent", "a\u00e9z"]] as const) { + it(`refuses a mirrored non-ASCII ${label} secret without typing anything (#1065)`, async () => { + // Given a real recorder showing a password prompt on a mirrored pane. + const target = await recorder(`secret-${label}`, process.execPath, "Password:"); + const bare = createServer({ port: 0, stateDir: join(root, `secret-${label}`), terminalAttach: false }); + const sender = await Socket.connect(bare.port); + const send = spyOn(herdr, "paneSendText"); + const keys = spyOn(herdr, "paneSendKeys"); + try { + await attached(sender, target.pane); + const from = chunks(target).length; + const result = sender.waitFor((message) => message.type === "secret-result" && message.id === 1065); + // When Unicode is submitted, even surrounded by printable ASCII. + sender.send({ type: "secret", id: 1065, pane_id: target.pane, prompt: "Password:", secret }); + const response = await result; + // Then no text or key RPC is issued, no bytes arrive, and the existing refusal is returned. + expect(send).not.toHaveBeenCalled(); + expect(keys).not.toHaveBeenCalled(); + expect(typed(target, from)).toBe(""); + expect(response).toMatchObject({ ok: false, code: "invalid_secret" }); + } finally { + send.mockRestore(); + keys.mockRestore(); + sender.close(); + bare.stop(); + } + }, 30_000); + } + + for (const transition of ["none", "rejoin", "observe", "observe-back", "enter-connect"] as const) { + const secret = "sensitive900"; + it(`clears a mirrored ASCII secret instead of entering it after ${transition} (#900)`, async () => { + // Given a real masked shell read and a second client keeping the mirror alive. + const label = `secret-ASCII-${transition}`; + const output = join(root, `${label}.result`); + const script = join(root, `${label}.sh`); + writeFileSync(script, `printf 'Password:'; read -s p; printf '%s' "$p" | od -An -tx1 | tr -d '[:space:]' > '${output}'\n`); + const created = await herdrRpc<{ workspace: { workspace_id: string }; root_pane: { pane_id: string } }>( + "workspace.create", { label: `herdr-web-ui-test-${label}`, cwd: root, focus: false }, + ); + workspaces.push(created.workspace.workspace_id); + const pane = created.root_pane.pane_id; + const bare = createServer({ port: 0, stateDir: join(root, label), terminalAttach: false }); + const sender = await Socket.connect(bare.port); + const keeper = await Socket.connect(bare.port); + const entered = deferred(); + const gate = deferred(); + const originalSend = herdr.paneSendText; + const originalKeys = herdr.paneSendKeys; + const send = spyOn(herdr, "paneSendText").mockImplementation(async (paneId, text, socketPath, guard) => { + await originalSend(paneId, text, socketPath, guard); + if (paneId === pane && text === secret && transition !== "enter-connect") { + entered.resolve(); + await gate.promise; + } + }); + const keys = spyOn(herdr, "paneSendKeys").mockImplementation(async (paneId, sent, socketPath, guard) => { + if (paneId === pane && sent.includes("Enter") && transition === "enter-connect") { + entered.resolve(); + await gate.promise; + } + return originalKeys(paneId, sent, socketPath, guard); + }); + let watcher: ReturnType | undefined; + let timer: ReturnType | undefined; + try { + // Subscribe before starting the reader; the command itself contains no prompt text. + const prompt = keeper.waitFor((message) => message.type === "pty-data" && message.pane_id === pane && message.data.includes("Password:")); + await attached(keeper, pane); + await herdr.paneSendText(pane, `exec bash '${script}'\n`); + await prompt; + await attached(sender, pane); + const completed = new Promise((resolve, reject) => { + watcher = watch(root, (_event, filename) => { + if (filename !== `${label}.result` || !existsSync(output)) return; + const text = readFileSync(output, "utf8"); + if (text) resolve(text); + }); + timer = setTimeout(() => reject(new Error("masked read did not finish within 5s")), 5000); + }); + const result = sender.waitFor((message) => message.type === "secret-result" && message.id === 900); + // When authority changes after the real text RPC, or while Enter connects. + sender.send({ type: "secret", id: 900, pane_id: pane, prompt: "Password:", secret }); + await entered.promise; + if (transition === "rejoin" || transition === "enter-connect") { + sender.send({ type: "detach", pane_id: pane }); + await attached(sender, pane); + } else if (transition === "observe" || transition === "observe-back") { + sender.send({ type: "role", mode: "observe" }); + await sender.waitFor((message) => message.type === "role-ack" && message.mode === "observe"); + if (transition === "observe-back") { + sender.send({ type: "role", mode: "interact" }); + await sender.waitFor((message) => message.type === "role-ack" && message.mode === "interact"); + } + } + gate.resolve(); + const response = await result; + // Then controls keep normal delivery; regressions refuse stale authority and erase the buffer. + if (transition === "none") { + expect(response).toMatchObject({ ok: true }); // control + expect(await completed).toBe(Buffer.from(secret).toString("hex")); // control + } else { + expect(response).toMatchObject({ + ok: false, code: transition.startsWith("observe") ? "read_only" : "not_attached", + }); // regression (fails on main) + expect(keys).toHaveBeenLastCalledWith(pane, Array(secret.length).fill("Backspace")); + await originalSend(pane, "X"); + await originalKeys(pane, ["Enter"]); + expect(await completed).toBe("58"); // only X; no residual secret bytes + } + } finally { + gate.resolve(); + watcher?.close(); + clearTimeout(timer); + send.mockRestore(); + keys.mockRestore(); + sender.close(); + keeper.close(); + bare.stop(); + } + }, 30_000); + } + it("presses no chord for a sender that switched to observe and back while herdr was being reached (#545)", async () => { const bare = createServer({ port: 0, stateDir: join(root, "push-role-keys"), terminalAttach: false }); const sender = await Socket.connect(bare.port); diff --git a/server/transcript-records.ts b/server/transcript-records.ts index e4cda7609..c17200e58 100644 --- a/server/transcript-records.ts +++ b/server/transcript-records.ts @@ -215,6 +215,11 @@ export function toolSummary(name: string, input: Record): strin const titles = items.map((item) => label(item["task_summary"]) ?? label(item["description"])).filter((title) => title !== null); if (titles.length > 0) return titles.join(" · ").slice(0, 120); } + // what the agent itself said the call is for: OmO's eval `summary`, the `description` Claude + // gives a Bash command or a subagent's prompt. Another tool's `description` is its own data. + const said = typeof input["code"] === "string" ? label(input["summary"]) + : typeof input["command"] === "string" || typeof input["prompt"] === "string" ? label(input["description"]) : null; + if (said !== null) return said.slice(0, 120); // pi names a file `path` where Claude names it `file_path`, and a notebook `notebook_path`. const first = input["command"] ?? input["file_path"] ?? input["notebook_path"] ?? input["path"] ?? input["pattern"] ?? input["description"] ?? input["url"]; return typeof first === "string" ? first.slice(0, 120) : name; diff --git a/server/windows-processes.ts b/server/windows-processes.ts index f6283a093..021dd40ed 100644 --- a/server/windows-processes.ts +++ b/server/windows-processes.ts @@ -18,9 +18,9 @@ export function windowsArgv(commandLine: string): string[] { return words; } -/** argv of every process below `pid`, nearest first; the executable's full path stands for argv[0]. */ -export function descendantArgv(rows: readonly ProcessRow[], pid: number): string[][] { - const found: string[][] = []; +/** Every process below `pid`, nearest first, by its number and its argv; the executable's full path stands for argv[0]. */ +export function descendantProcesses(rows: readonly ProcessRow[], pid: number): { pid: number; argv: string[] }[] { + const found: { pid: number; argv: string[] }[] = []; const seen = new Set([pid]); let level = [pid]; while (level.length > 0) { @@ -30,13 +30,18 @@ export function descendantArgv(rows: readonly ProcessRow[], pid: number): string seen.add(row.pid); next.push(row.pid); const argv = windowsArgv(row.commandLine ?? ""); - found.push(row.path ? [row.path, ...argv.slice(1)] : argv); + found.push({ pid: row.pid, argv: row.path ? [row.path, ...argv.slice(1)] : argv }); } level = next; } return found; } +/** argv of every process below `pid`, nearest first. */ +export function descendantArgv(rows: readonly ProcessRow[], pid: number): string[][] { + return descendantProcesses(rows, pid).map((entry) => entry.argv); +} + export function parseProcessTable(json: string): ProcessRow[] { let value: unknown; try { value = JSON.parse(json); } catch { return []; } diff --git a/shared/AGENTS.md b/shared/AGENTS.md deleted file mode 100644 index 22410872f..000000000 --- a/shared/AGENTS.md +++ /dev/null @@ -1,34 +0,0 @@ -# shared/ - -The HTTP/WS contract plus the few policy modules the browser and the server have to agree on. A hand-written change here changes both sides at once; the generated file is regenerated, never edited. - -## CONTRACT -- `protocol.ts` carries the route catalogue in doc comments above the types: method, path, answer and the quirk of each one. Voice routes are catalogued in `voice.ts` the same way. A route changed without its comment leaves that index wrong. -- Importing a VALUE from `protocol.ts` also evaluates `HERDR_SOCKET_PATH`, which reads `process.env` at module scope. Constants the browser needs therefore live in their own files (`attachments.ts`, `terminal-flow.ts`); keep `src/` imports from `protocol.ts` type-only. -- The generated herdr types are re-exported from here, and `generate:types --check` compares them with the saved `scripts/herdr-schema.json`: a herdr upgrade shows only after `--refresh` re-reads its schema, so refresh first instead of trusting a passing check. Their string enums arrive widened with `(string & {})`: never narrow one back to a closed union. -- `HerdrPane` is herdr's `PaneInfo` widened with `background_tasks`. The bridge's own additions to a herdr type belong on an alias here, never in the generated file. -- `ServerFeature` is what a client checks before it uses `submit`, `pending-input`, `secret-input`, `input-ready` or `take-over`: an older server simply omits one, so never assume a frame is supported. -- `patch.test.ts` and `secret-prompt.test.ts` are the only tests in this directory; other shapes are held by the server's contract tests and the client's unit tests. - -## ROUTE SHAPES -- Legacy paths and a missing machine ID still mean the local PC. `/ws?machine_id=` fixes the target for the life of the socket, with the role and output-ACK protocol unchanged. -- `GET /api/health?scope=bridge` answers without waiting for herdr, which is what makes it usable while herdr is down. -- `/api/pane/scroll` reports where the viewport sits as `max_offset_from_bottom - offset_from_bottom`, and posting an offset makes herdr redraw every attached terminal: it is not a client-local scroll. `/api/pane/find` returns native inclusive cell ranges (at most 1024 around the result), an exact total and scroll metrics. `matches` and `scroll` stay optional for older bridges; their presence on an explicit response is what permits automatic `jump:false` refreshes. Never probe an older bridge with `jump:false`: it may ignore that field and move the shared view. -- `/api/pane/selection` takes both cells inclusive, counts rows from the top of the history and joins soft-wrapped lines, so a selection outlives one screen. -- `/api/agents` lists herdr's own agent manifests plus omo and gjc when they are on the server's PATH, so the dialog can offer a kind `agent.start` would refuse. -- `UsageReport` names only the providers a CLI on this PC is signed in to, once per account, and a provider that could not answer is reported as a `UsageProblem` (`expired`, `rate_limited`, `failed`, `locked`) rather than left out. - -## SHARED POLICY -- `notify-policy.ts` is shared by the browser's tab notifications and the server's web push on purpose, so a device with the app open and a phone with it closed judge an event by the same rule; permission, preferences and subscriptions still decide what each path shows. A new rule belongs here, not in either caller. `paneNotificationTag` gives one slot per pane, and a newer notification replaces the older whichever path showed it. -- Unknown alert values fall back to the default (`parseAlerts`, `alertsAllow`): a device must never lose its alerts to a typo or to a field a newer build writes. -- `machines.ts` `terminal_attach: false` is a working PC with the chat lens and a mirrored terminal, not a broken one. It is false on a Windows host (herdrdev/herdr#4821) and on any bridge whose runtime ships no PTY sidecar, and absent on older bridges that do not report it. -- `update.ts` release summaries are keyed by the app's own languages (`src/lib/i18n.ts`): a language the release did not write is absent, and a list with no line is absent rather than empty. -- `voice.ts`: `base_url` moves only together with `api_key` while a key is saved, and never while the environment sets the key, so a saved key cannot be pointed at a server other than the one it was saved for. - -## ANTI-PATTERNS -- `secret-prompt.ts` is deliberately narrow: prose that mentions a password must not become a secret input. A secret is one line of literal keystrokes and never terminal controls, and only a row that reached the right edge may be joined to the next, because herdr's screen-diff stream does not preserve xterm's soft-wrap flags. -- Secret input is never queued, retried, sent through `agent.prompt` or echoed in a result, and a queued message does not resume after its lease is lost. -- Never replay terminal input across a disconnect, acknowledge pty output before xterm's write callback ran, repaint raw pty bytes over the terminal, or let an observe-role client input or resize. `protocol.ts` documents most of these on their frames; the client and the server enforce them. -- `MAX_ATTACHMENT_BYTES` is checked by the browser before it reads any of a file and again by `POST /api/pane/image`. Raising the number alone is not enough: the upload is one JSON body the server holds whole. -- `patch.ts` has to find Codex's patch in both carriers — `apply_patch`'s whole input, and the string argument of `tools.apply_patch(…)` inside an `exec` script — or the edit reads as a raw blob with no file names. -- `background_tasks` is absent from a snapshot when none run, and a `pane-status` frame sends 0 to clear an earlier count. `ConversationResponse.source` is `"scrollback"` when the pane has no recognized store: the chat is read off the screen, which says nothing about whether the agent has a history. diff --git a/shared/attachments.ts b/shared/attachments.ts index bf27ec372..647445571 100644 --- a/shared/attachments.ts +++ b/shared/attachments.ts @@ -1,7 +1,8 @@ /** * The largest file a pane takes as an attachment. The browser checks a file against it before * reading or sending any of it, and POST /api/pane/image refuses what is over it. It dates from - * pasted screenshots (0.1-2 MB each, so 8 MB left headroom); raising it is not only this - * number, since the upload is one JSON body the server holds whole. + * pasted screenshots; 32 MB takes an archive, a PDF or a short recording too. Raising it is not + * only this number: the upload is one base64 JSON body the server holds whole (about 4/3 of the + * file, plus the copy it decodes into), and the route bounds that body itself (server/paste.ts). */ -export const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024; +export const MAX_ATTACHMENT_BYTES = 32 * 1024 * 1024; diff --git a/shared/herdr-api.generated.ts b/shared/herdr-api.generated.ts index d0a8da673..4b59057e9 100644 --- a/shared/herdr-api.generated.ts +++ b/shared/herdr-api.generated.ts @@ -64,6 +64,10 @@ export interface AgentStartParams { export type AgentStatus = "blocked" | "done" | "idle" | "unknown" | "working" | (string & {}); +export interface AgentTarget { + target: string; +} + export interface EventsSubscribeParams { subscriptions: Subscription[]; } @@ -126,44 +130,25 @@ export interface PaneLayoutSplit { rect: PaneLayoutRect; } -export type PaneMoveDestination = { - ratio?: number | null; - split: SplitDirection; - tab_id: string; - target_pane_id?: string | null; - type: string; -} | { - label?: string | null; - type: string; - workspace_id?: string | null; -} | { - label?: string | null; - tab_label?: string | null; - type: string; -}; - -export interface PaneMoveParams { - destination: PaneMoveDestination; - focus?: boolean; +export interface PaneProcessInfo { + foreground_process_group_id?: number | null; + foreground_processes?: PaneProcessInfoProcess[]; pane_id: string; + shell_pid?: number | null; + tty?: string | null; } -export type PaneMoveReason = "same_tab" | "zoomed_tab" | (string & {}); +export interface PaneProcessInfoParams { + pane_id?: string | null; +} -export interface PaneMoveResult { - changed: boolean; - closed_tab_id?: string | null; - closed_workspace_id?: string | null; - created_tab?: TabInfo | null; - created_workspace?: WorkspaceInfo | null; - focused_pane_id: string; - pane: PaneInfo; - previous_pane_id: string; - previous_tab_id: string; - previous_workspace_id: string; - reason?: PaneMoveReason | null; - source_layout?: PaneLayoutSnapshot | null; - target_layout: PaneLayoutSnapshot; +export interface PaneProcessInfoProcess { + argv?: string[] | null; + argv0?: string | null; + cmdline?: string | null; + cwd?: string | null; + name: string; + pid: number; } export interface PaneReadParams { diff --git a/shared/labels.ts b/shared/labels.ts new file mode 100644 index 000000000..9fb88d11b --- /dev/null +++ b/shared/labels.ts @@ -0,0 +1,5 @@ +/** + * The longest workspace or tab name the bridge sets, in UTF-16 code units (what an input's + * `maxLength` counts). The server refuses a longer one and the name fields stop there (#927). + */ +export const MAX_LABEL_CHARS = 80; diff --git a/shared/preferences.ts b/shared/preferences.ts index 10772e929..0fe811ad6 100644 --- a/shared/preferences.ts +++ b/shared/preferences.ts @@ -7,9 +7,9 @@ * which the server lays over what it holds, so a setting nobody changed stays each device's own. */ export const SHARED_SETTING_KEYS = [ - "theme", "palette", "language", "sidebarRows", "agentOrder", "agentCards", "agentPins", "agentMarks", "autoSendQueued", - "showThinking", "quickReplies", "showQuickReplies", "showSuggestionChip", "showUsage", "usageCount", "usagePace", - "usageGlance", "usageOrder", "usageHidden", "highlightCode", + "theme", "palette", "language", "sidebarRows", "agentOrder", "agentMarks", "autoSendQueued", + "showThinking", "quickReplies", "showQuickReplies", "showUsage", "usageCount", + "usageGlance", "usageOrder", "usageHidden", ] as const; export type SharedSettingKey = (typeof SHARED_SETTING_KEYS)[number]; diff --git a/shared/protocol.ts b/shared/protocol.ts index 9a4550208..630d0139b 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -12,8 +12,6 @@ export type { AgentSessionInfo, AgentStatus, PaneInfo, - PaneMoveReason, - PaneMoveResult, PaneLayoutPane, PaneLayoutRect, PaneLayoutSnapshot, @@ -22,13 +20,12 @@ export type { ReadFormat, ReadSource, SessionSnapshot, - SplitDirection, Subscription as HerdrSubscriptionSpec, TabInfo, WorkspaceInfo, } from "./herdr-api.generated.ts"; -import type { AgentStatus, PaneInfo, PaneMoveResult, PaneScrollInfo, SessionSnapshot, SplitDirection, TabInfo, WorkspaceInfo } from "./herdr-api.generated.ts"; +import type { AgentStatus, PaneInfo, PaneScrollInfo, SessionSnapshot, TabInfo, WorkspaceInfo } from "./herdr-api.generated.ts"; /** Friendly aliases used across the UI. */ export type HerdrWorkspace = WorkspaceInfo; @@ -86,7 +83,8 @@ export type { Machine, MachineEvent, PaneTarget, SetupJob, SetupRequest, SetupAc * 404 from a server that sends no telemetry. * GET /api/agents -> { agents: AgentKind[] } (herdr's agent manifests: the * kinds `agent.start` accepts, plus omo and gjc when they are on this server's PATH, - * for the new-session dialog) + * each with `installed`: whether its command is on this server's PATH; for the + * new-session dialog) * GET /api/integrations -> IntegrationsResponse (herdr's `integration.list`, in * herdr's order; read only: nothing here installs or removes one, Settings shows the * `herdr integration install` command instead) @@ -131,11 +129,6 @@ export type { Machine, MachineEvent, PaneTarget, SetupJob, SetupRequest, SetupAc * POST /api/pane/rename { pane_id, label } -> { ok: true } (pane.rename; empty label clears it) * POST /api/pane/focus { pane_id } -> { ok: true } (pane.focus: herdr brings its workspace, * tab and pane to the front, also while the tab is zoomed) - * POST /api/pane/move { pane_id, destination, focus? } -> PaneMoved (pane.move: the pane into - * another tab of its workspace, a new tab there or in another workspace, or a new workspace; - * a pane that leaves its workspace gets a NEW pane id, reported beside previous_pane_id; an - * emptied tab or workspace closes behind it; focus defaults to false, so herdr's own focus - * stays where it was and only the caller follows the pane) * POST /api/agent/rename { pane_id, name } -> { ok: true } (agent.rename on the pane's live agent: * the name other tools address it by, `herdr agent prompt `; null clears it. herdr * owns the rule (shared/agent-name.ts) and answers invalid_agent_name, agent_name_taken or, @@ -173,6 +166,20 @@ export type { Machine, MachineEvent, PaneTarget, SetupJob, SetupRequest, SetupAc * keeps: its task list or checklist, read from the transcript; null when it keeps none) * GET /api/pane/files?pane_id=&q=&limit= -> { files: string[] } (paths relative to the pane * cwd matching q, for @-mentions; git ls-files when the cwd is a repo, bounded walk otherwise) + * GET /api/workspace/diff/files?pane_id=&directory=&scope=&base=&head= -> RepoDiffFileList + * (what changed in the git repository the pane's folder, or `directory`, is inside: read + * only, on the PC that owns the pane. `directory` is absolute, `~/…`, or relative to the + * pane's folder; one of pane_id and directory is required. scope: workspace (the default: + * working tree against the index, plus untracked files), staged (index against HEAD) or + * commit (`base` against `head`, HEAD when absent; each one commit). The list is capped + * (`limits`), and `truncated` says when it was. 400 invalid_scope / invalid_revision / + * invalid_cwd / missing_directory, 404 revision_not_found, 409 not_a_repository, + * 503 git_unavailable, 504 git_timeout; 403 read_only for a watching device, as a file read) + * GET /api/workspace/diff/file?pane_id=&directory=&scope=&base=&head=&path=&old_path= + * -> RepoDiffFilePatch (one file of that comparison as a standard `diff --git` unified + * patch; `path` and, for a rename, `old_path` are the list's own, relative to the + * repository root. The patch is whole or null: `unavailable` says why it is null, and no + * cut hunk is ever sent. 400 invalid_path, 404 not_found when nothing changed there) * GET /api/pane/prompt?pane_id= -> { prompt: InteractivePrompt | null, suggestion: string | null, restored: string | null } * (the agent's TUI question/approval menu currently on screen, parsed from the visible pane * text; with no menu, the next prompt Claude Code suggests, grey in its empty input box, and @@ -186,20 +193,25 @@ export type { Machine, MachineEvent, PaneTarget, SetupJob, SetupRequest, SetupAc * POST /api/tab/create { workspace_id, cwd?, label?, agent?: { kind, name?, args? } } * -> TabCreated (tab.create in that workspace, then the same agent launch in the tab's root * pane; without cwd herdr uses the workspace's folder, without label the tab's number) + * Both creates trim `label`; a blank one counts as not given, one over MAX_LABEL_CHARS + * (shared/labels.ts) is refused with 400 label_too_long. * POST /api/tab/rename { tab_id, label } -> { ok: true } (tab.rename; an empty label is refused: - * herdr would keep it as the name) + * herdr would keep it as the name; over MAX_LABEL_CHARS: 400 label_too_long) * POST /api/tab/move { tab_id, insert_index } -> TabMoved * (tab.move: an insertion boundary in the original workspace tab order, 0..tab count; * preserves the active tab/pane and returns the authoritative workspace tab order) * POST /api/tab/close { tab_id } -> { ok: true } (tab.close: every pane in the tab closes, and * a workspace's last tab takes the workspace with it) - * POST /api/workspace/rename { workspace_id, label } -> { ok: true } + * POST /api/workspace/rename { workspace_id, label } -> { ok: true } (trimmed; empty: 400 + * missing_label, over MAX_LABEL_CHARS: 400 label_too_long) * POST /api/workspace/move { workspace_id, insert_index } -> { ok: true } (sidebar reorder) * POST /api/workspace/close { workspace_id, close_group? } -> { ok: true } (close_group takes the * repository's open worktree workspaces with it; without it herdr refuses: workspace_group_close_required) * POST /api/worktree/create { workspace_id, branch, base?, label?, path?, agent?: { kind, name?, args? } } -> WorktreeOpened * (worktree.create: a git worktree of that workspace's repo, checked out under herdr's - * worktree directory unless `path` says where, and opened as a new workspace grouped with it) + * worktree directory unless `path` says where, and opened as a new workspace grouped with it; + * 409 repository_without_commits when no base is named and the repository has no commit yet; + * both worktree routes refuse a label over MAX_LABEL_CHARS with 400 label_too_long) * GET /api/worktree/list?workspace_id= -> WorktreeListing (worktree.list: the repo's checkouts) * POST /api/worktree/open { workspace_id, path | branch, label? } -> WorktreeOpened * (worktree.open: an existing checkout as a workspace; already_open names the one it has) @@ -216,8 +228,10 @@ export type { Machine, MachineEvent, PaneTarget, SetupJob, SetupRequest, SetupAc * PATCH /api/devices/:id { label } -> PairedDevice; DELETE /api/devices/:id -> 204 (revoked at its next request) * GET /api/push -> PushKey (the VAPID application server key) * POST /api/push/subscribe { subscription } -> 204 (a browser PushSubscription JSON; upsert by endpoint) + * | 410 subscription_gone (its push service answered 404/410 for that endpoint since the server + * started: the browser must unsubscribe and subscribe anew; an older server never says it) * DELETE /api/push/subscribe { endpoint } -> 204 - * POST /api/push/test { endpoint } -> 204 | 404 subscription_not_found | 502 push_failed + * POST /api/push/test { endpoint } -> 204 | 404 subscription_not_found | 410 subscription_gone | 502 push_failed * GET /api/usage[?refresh=1] -> UsageReport (plan limits of the AI subscriptions signed in on this PC) * Errors: non-2xx with { error: { code, message } } * @@ -680,9 +694,81 @@ export interface FileInfo { kind: FileKind; } +/** Which two states of a repository are compared. */ +export type RepoDiffScope = "workspace" | "staged" | "commit"; + +/** How one file changed. `untracked` exists only in the `workspace` scope. */ +export type RepoDiffStatus = "added" | "deleted" | "modified" | "renamed" | "copied" | "type_changed" | "unmerged" | "untracked"; + +/** The repository a comparison was read from, on the PC that owns it. */ +export interface RepoDiffRepository { + /** the working tree's top folder, absolute */ + root: string; + /** the checked-out branch; null on a detached HEAD */ + branch: string | null; + /** HEAD's commit hash; null before the first commit */ + head: string | null; + detached: boolean; + /** no commit yet: `staged` then lists everything added so far */ + unborn: boolean; +} + +export interface RepoDiffFile { + /** relative to the repository root, forward slashes, as git names it (never quoted) */ + path: string; + /** where a renamed or copied file came from; null otherwise */ + old_path: string | null; + status: RepoDiffStatus; + /** lines added and removed; null for a binary file, an untracked one, and past the list's byte cap */ + additions: number | null; + deletions: number | null; + /** null when not determined: an untracked file in a list is not opened to find out */ + binary: boolean | null; +} + +/** GET /api/workspace/diff/files */ +export interface RepoDiffFileList { + repository: RepoDiffRepository; + scope: RepoDiffScope; + /** the commits compared, as full hashes, in the `commit` scope; null in the others */ + base: string | null; + head: string | null; + /** sorted by path, at most `limits.max_files` */ + files: RepoDiffFile[]; + /** how many files changed in all; null when git printed more than `limits.max_list_bytes` */ + total: number | null; + /** `files` is not the whole list */ + truncated: boolean; + limits: { max_files: number; max_list_bytes: number }; +} + +/** + * GET /api/workspace/diff/file. `patch` is git's own output for that one file, from its + * `diff --git` line on, and is either complete or null. + */ +export interface RepoDiffFilePatch { + repository: RepoDiffRepository; + scope: RepoDiffScope; + base: string | null; + head: string | null; + file: RepoDiffFile; + patch: string | null; + /** + * Why there is no patch: `binary` (no text to show), `too_large` (past `limits.max_patch_bytes`), + * `unmerged` (a conflict: git prints a combined diff, which is not a unified patch) or + * `unsupported` (an untracked path that is not a regular file). Null when `patch` is set. + */ + unavailable: "binary" | "too_large" | "unmerged" | "unsupported" | null; + /** the patch exists and was left out for its size */ + truncated: boolean; + limits: { max_patch_bytes: number; context_lines: number }; +} + export interface AgentKind { kind: string; label: string; + /** whether the server finds the agent's command (its kind) on its PATH; absent from an older bridge */ + installed?: boolean; } /** @@ -810,33 +896,6 @@ export interface WorkspaceCreated { */ export type TabCreated = WorkspaceCreated; -/** - * POST /api/pane/move: where the pane goes. herdr's PaneMoveDestination with the `type` the - * generated union widens to a string spelled out, so a caller cannot send a kind herdr has not got. - * `tab` lands beside the tab's focused pane (`target_pane_id` names another) on the side `split` - * says, right when absent; `new_tab` without a workspace stays in the pane's own. - */ -export type MovePaneDestination = - | { type: "tab"; tab_id: string; split?: SplitDirection; target_pane_id?: string | null; ratio?: number | null } - | { type: "new_tab"; workspace_id?: string | null; label?: string | null } - | { type: "new_workspace"; label?: string | null; tab_label?: string | null }; - -export interface MovePaneRequest { - pane_id: string; - destination: MovePaneDestination; - /** herdr's focus follows the pane when true; absent or false leaves it where it was */ - focus?: boolean; -} - -/** - * POST /api/pane/move: herdr's move result. `pane` is the pane where it now stands, under a new - * `pane_id` when it changed workspace (`previous_pane_id` is the one the caller sent); `changed` - * false with `reason: "same_tab"` means it already was where it was asked to go. `created_tab` - * and `created_workspace` name what the move made, `closed_tab_id` and `closed_workspace_id` what - * it emptied and herdr closed behind it. - */ -export type PaneMoved = PaneMoveResult; - /** The two ways herdr splits a pane: a new pane to its right, or below it. */ export type SplitPaneDirection = "right" | "down"; @@ -988,6 +1047,8 @@ export interface PushPayload { /** Absent in legacy payloads means local. */ machine_id?: string; pane_id: string | null; + /** The workspace the pane was last seen in, so the tap of an ended pane opens that workspace. Absent when not known. */ + workspace_id?: string; title: string; body: string; tag: string; @@ -996,7 +1057,7 @@ export interface PushPayload { /** WebSocket at /ws * * Client -> server frames: attach {pane_id, cols, rows} | detach {pane_id} | input {pane_id, text} - * | keys {pane_id, keys} | resize {pane_id, cols, rows} | role {mode} + * | keys {pane_id, keys} | resize {pane_id, cols, rows} | release-size {pane_id} | role {mode} * | pty-ack {pane_id, stream_id, offset} | secret {id, pane_id, prompt, secret} * | watch {pane_id, cols, rows} | unwatch {pane_id} | conversation-watch {pane_id, enabled} * Server -> client frames: snapshot | pty-data | pty-exit | pane-geometry | role-ack @@ -1045,7 +1106,9 @@ export type ClientMessage = | { type: "conversation-watch"; pane_id: string; enabled: boolean } /** a pane another web bridge holds (`attach_held`): take herdr's attach slot from it, here, now */ | { type: "take-over"; pane_id: string } - | { type: "input"; pane_id: string; text: string } + /** `stop`: the composer's Stop (text a lone ESC). The bridge sends it in the pane's turn, after typing + * still on its way, and a message sent next waits for it; an older bridge types it like any key. */ + | { type: "input"; pane_id: string; text: string; stop?: true } | { type: "keys"; pane_id: string; keys: string[] } /** a composer message, sent to servers whose snapshot lists "submit": the server types it and * its own Enter after a short gap, and answers with a submit-result of the same id. `text` is @@ -1061,13 +1124,27 @@ export type ClientMessage = * receipt, and retired ids are rejected rather than executed after history eviction. * `clear_draft`: an immediate chat message only, sent again over the `draft` its input_draft * refusal named: the server clears Claude's input box first while it still holds exactly that, - * and refuses again (input_draft) when it holds anything else or does not read empty after. */ - | { type: "submit"; id: number; pane_id: string; text: string; payload: string; typed?: boolean; delivery?: "immediate" | "queue" | "hold"; clear_draft?: string } - | { type: "pending-action"; id: number; pane_id: string; pending_id: string; action: "steer" | "discard" } + * and refuses again (input_draft) when it holds anything else or does not read empty after. + * `send_anyway`: an immediate chat message only, sent again after an input_draft refusal that + * named no draft and showed the box (`box`): the server sends once over that box, only into the same + * Claude terminal and session that refusal was for (else pending_target_changed), and only while the + * box still shows that `box` or reads empty (else input_draft again, with the new box). Not with + * `clear_draft`. + * `resend_id`: stable held-copy ID for immediate chat resends across connections; the bridge + * shares the first outcome for 60 seconds after settlement. `resend_generation` defaults to 0; + * only a definitive no-input refusal permits the next attempt generation. Not with `send_anyway`: + * that combination returns invalid_delivery before reserving or replaying an ID. No `clear_draft`. + * Older peers ignore it. */ + | { type: "submit"; id: number; pane_id: string; text: string; payload: string; typed?: boolean; delivery?: "immediate" | "queue" | "hold"; clear_draft?: string; send_anyway?: true; resend_id?: string; resend_generation?: number } + | { type: "pending-action"; id: number; pane_id: string; pending_id: string; action: "steer" | "discard"; resend_generation?: number } /** Masked input: revalidate the visible prompt, type literal bytes + Enter immediately. * Never queued, retried, sent through agent.prompt, or echoed in a result. */ | { type: "secret"; id: number; pane_id: string; prompt: string; secret: string } | { type: "resize"; pane_id: string; cols: number; rows: number } + /** this client's terminal is covered again (the chat lens): if the pty has its grid, the server sizes + * the pty for the latest grid of another interact client still showing the terminal, or leaves it. + * Never answered; an older server ignores it, so it needs no feature. */ + | { type: "release-size"; pane_id: string } /** Cumulative UTF-8 payload bytes processed by xterm, only for this subscription. */ | { type: "pty-ack"; pane_id: string; stream_id: string; offset: number } | { type: "role"; mode: ClientRole } @@ -1078,10 +1155,15 @@ export type ClientMessage = | { type: "presence"; active: boolean } /** a read-only view of the pane at this grid, for a tab out of use (feature "watch"); never resizes the pane */ | { type: "watch"; pane_id: string; cols: number; rows: number } - | { type: "unwatch"; pane_id: string }; + | { type: "unwatch"; pane_id: string } + /** liveness only (feature "ping"): answered at once with a `pong` of the same id, for any role. The + * client sends it when the page comes back, the network changes or nothing arrived for a while, + * and drops a socket that stays silent: a half-open one still reads OPEN in the browser. A remote + * PC's socket relays it unchanged, so the remote server's snapshot decides whether it is sent. */ + | { type: "ping"; id: number }; /** What a server supports beyond the base protocol, listed in its first snapshot; older bridges list nothing. */ -export type ServerFeature = "submit" | "pending-input" | "pending-hold" | "secret-input" | "input-ready" | "take-over" | "watch" | "conversation-watch"; +export type ServerFeature = "submit" | "pending-input" | "pending-hold" | "secret-input" | "input-ready" | "take-over" | "watch" | "conversation-watch" | "ping"; export type ServerMessage = | { type: "snapshot"; snapshot: SessionSnapshot; features?: ServerFeature[] } @@ -1098,9 +1180,12 @@ export type ServerMessage = /** ok after Enter delivery, or bridge acceptance (`pending`); never an agent execution acknowledgement. * `draft`: on an immediate send's input_draft, the one-row draft in Claude's input box that a send * with `clear_draft` may clear (absent when the box is not one the server clears). + * `box`: on an immediate send's input_draft that names no draft, what the server read in Claude's + * input box (plain rows, cut at 200 characters; empty when no whole box could be read). Present + * only when a send with `send_anyway` may follow; an older server never sends it. * `submit_partial`: the explicitly confirmed terminal draft was cleared, but the send's * deadline then expired before the message was sent; never a nothing-typed receipt. */ - | { type: "submit-result"; id: number; pane_id: string; ok: boolean; pending?: PendingMessage; code?: string; message?: string; draft?: string } + | { type: "submit-result"; id: number; pane_id: string; ok: boolean; pending?: PendingMessage; code?: string; message?: string; draft?: string; box?: string } | { type: "pending-result"; id: number; pane_id: string; pending_id: string; ok: boolean; code?: string; message?: string } /** Owner-only receipt and state; only an explicit removed outcome permits deleting a retained client message. */ | { type: "pending-messages"; pane_id: string; messages: PendingMessage[]; removed?: Array<{ id: string; outcome: "sent" | "discarded" }> } @@ -1117,6 +1202,8 @@ export type ServerMessage = | { type: "watch-data"; pane_id: string; data: string } /** the watch ended (pane gone, herdr closed it, or this herdr has no read-only view); the client keeps its last screen */ | { type: "watch-end"; pane_id: string } + /** the answer to a `ping`, with its id; only this frame, for a ping in flight, ends the client's liveness probe */ + | { type: "pong"; id: number } /** `pane_id` names the pane an error is about, when it is about one (`attach_held`) */ | { type: "error"; code: string; message: string; pane_id?: string }; diff --git a/shared/supported-agents.ts b/shared/supported-agents.ts new file mode 100644 index 000000000..db2a99219 --- /dev/null +++ b/shared/supported-agents.ts @@ -0,0 +1,7 @@ +/** + * The agent kinds this app offers to start, where they are installed: the ones docs/guide.md lists + * under Supported agents. herdr's manifests name more; any of those still runs from a pane's shell. + * + * Its own file: the browser imports the value, and shared/protocol.ts reads `process.env` as it loads. + */ +export const SUPPORTED_AGENT_KINDS: ReadonlySet = new Set(["claude", "codex", "omp", "omo", "gjc", "pi", "opencode", "devin", "grok", "hermes", "agy"]); diff --git a/shared/telemetry.ts b/shared/telemetry.ts index 4ca0bf216..9adc75a45 100644 --- a/shared/telemetry.ts +++ b/shared/telemetry.ts @@ -33,35 +33,32 @@ export interface TelemetrySettings { density: "compact" | "comfortable"; sidebarRows: "one" | "two"; agentOrder: "workspace" | "activity"; - agentCards: boolean; - agentPins: boolean; - quietOpenedDone: boolean; agentMarks: "color" | "mono"; - palette: "amber" | "report" | "charcoal" | "catppuccin" | "lilac" | "forest"; + palette: "amber" | "charcoal" | "catppuccin"; + /** the pair System mode switches between */ + paletteLight: "amber" | "charcoal" | "catppuccin"; + paletteDark: "amber" | "charcoal" | "catppuccin"; terminalFontSize: number; - terminalCursorStyle: "block" | "bar" | "underline"; - terminalCursorBlink: boolean; terminalWheelSpeed: number; paneClipboard: boolean; releasePaneAway: boolean; - narrowPaneFont: boolean; chatFontSize: number | null; + /** null: the size the screen chose */ + uiFontSize: number | null; chatWidth: "narrow" | "default" | "wide" | "full"; enterSends: boolean; autoSendQueued: boolean; showThinking: boolean; keepScreenOn: boolean; - language: "system" | "en" | "zh" | "ja" | "ko" | "tr"; + language: "system" | "en" | "zh" | "ja" | "ko"; alertsOn: boolean; alertInput: boolean; alertDone: "off" | "long" | "always"; alertInApp: boolean; alertSound: boolean; showQuickReplies: boolean; - showSuggestionChip: boolean; showUsage: boolean; usageCount: "used" | "left"; - usagePace: boolean; usageGlance: "week" | "session"; defaultView: "auto" | "chat" | "terminal"; voiceInput: "auto" | "on" | "off"; @@ -69,8 +66,8 @@ export interface TelemetrySettings { voicePolishChat: boolean; voicePolishTerminal: boolean; wrapCode: boolean; - highlightCode: boolean; markdownWidth: "readable" | "full"; + diffStyle: "unified" | "split"; terminalFontCustom: boolean; chatFontCustom: boolean; shortcutsCustom: boolean; @@ -78,11 +75,8 @@ export interface TelemetrySettings { quickRepliesCustom: boolean; quickReplyCount: number; usageHiddenCount: number; - /** paging shortcuts (#811): lines kept between pages, and plain PageUp/PageDown while composing */ - pageOverlapRows: number; - chatPageKeys: boolean; - /** files one paste, pick or drop may attach (#863) */ - attachLimit: number; + /** the usage meters' accounts were put in an order of the user's own */ + usageOrderCustom: boolean; } /** The browser's full, non-sensitive snapshot. */ @@ -102,41 +96,37 @@ const nullableIntegerBetween = (min: number, max: number) => (value: unknown): b value === null || integerBetween(min, max)(value); // This explicit shared schema must track the producer without importing frontend code at runtime. +const PALETTES = ["amber", "charcoal", "catppuccin"] as const; const TELEMETRY_SETTINGS_VALIDATORS = { terminalInputMode: oneOf(["auto", "line", "direct"]), theme: oneOf(["dark", "light", "system"]), density: oneOf(["compact", "comfortable"]), sidebarRows: oneOf(["one", "two"]), agentOrder: oneOf(["workspace", "activity"]), - agentCards: booleanValue, - agentPins: booleanValue, - quietOpenedDone: booleanValue, agentMarks: oneOf(["color", "mono"]), - palette: oneOf(["amber", "report", "charcoal", "catppuccin", "lilac", "forest"]), + palette: oneOf(PALETTES), + paletteLight: oneOf(PALETTES), + paletteDark: oneOf(PALETTES), terminalFontSize: integerBetween(10, 22), - terminalCursorStyle: oneOf(["block", "bar", "underline"]), - terminalCursorBlink: booleanValue, terminalWheelSpeed: integerBetween(1, 10), paneClipboard: booleanValue, releasePaneAway: booleanValue, - narrowPaneFont: booleanValue, chatFontSize: nullableIntegerBetween(11, 24), + uiFontSize: nullableIntegerBetween(11, 20), chatWidth: oneOf(["narrow", "default", "wide", "full"]), enterSends: booleanValue, autoSendQueued: booleanValue, showThinking: booleanValue, keepScreenOn: booleanValue, - language: oneOf(["system", "en", "zh", "ja", "ko", "tr"]), + language: oneOf(["system", "en", "zh", "ja", "ko"]), alertsOn: booleanValue, alertInput: booleanValue, alertDone: oneOf(["off", "long", "always"]), alertInApp: booleanValue, alertSound: booleanValue, showQuickReplies: booleanValue, - showSuggestionChip: booleanValue, showUsage: booleanValue, usageCount: oneOf(["used", "left"]), - usagePace: booleanValue, usageGlance: oneOf(["week", "session"]), defaultView: oneOf(["auto", "chat", "terminal"]), voiceInput: oneOf(["auto", "on", "off"]), @@ -144,8 +134,8 @@ const TELEMETRY_SETTINGS_VALIDATORS = { voicePolishChat: booleanValue, voicePolishTerminal: booleanValue, wrapCode: booleanValue, - highlightCode: booleanValue, markdownWidth: oneOf(["readable", "full"]), + diffStyle: oneOf(["unified", "split"]), terminalFontCustom: booleanValue, chatFontCustom: booleanValue, shortcutsCustom: booleanValue, @@ -153,12 +143,11 @@ const TELEMETRY_SETTINGS_VALIDATORS = { quickRepliesCustom: booleanValue, quickReplyCount: integerBetween(0, 12), usageHiddenCount: integerBetween(0, 64), - pageOverlapRows: integerBetween(0, 100), - chatPageKeys: booleanValue, - attachLimit: integerBetween(1, 50), + usageOrderCustom: booleanValue, } satisfies Record boolean>; -const TELEMETRY_SETTINGS_KEYS = Object.keys(TELEMETRY_SETTINGS_VALIDATORS) as (keyof TelemetrySettings)[]; +/** Every key the Worker (telemetry/worker.ts) accepts in a snapshot, and requires. */ +export const TELEMETRY_SETTINGS_KEYS = Object.keys(TELEMETRY_SETTINGS_VALIDATORS) as (keyof TelemetrySettings)[]; /** Validate the complete snapshot and return a fresh object containing only schema keys. */ export function telemetrySettings(value: unknown): TelemetrySettings | null { diff --git a/shared/update.ts b/shared/update.ts index 249ee0443..cb865870d 100644 --- a/shared/update.ts +++ b/shared/update.ts @@ -27,7 +27,7 @@ export type UpdateStep = typeof UPDATE_STEPS[number]; export type UpdateCommand = "check" | "install"; /** The languages a release's summary is written in: the app's own (src/lib/i18n.ts). */ -export const SUMMARY_LANGUAGES = ["en", "ko", "ja", "zh", "tr"] as const; +export const SUMMARY_LANGUAGES = ["en", "ko", "ja", "zh"] as const; export type SummaryLanguage = typeof SUMMARY_LANGUAGES[number]; /** The lists a summary is told in, in the order they are read. */ export const SUMMARY_GROUPS = ["new", "improved", "fixed"] as const; diff --git a/site/demo/history.ts b/site/demo/history.ts index 63dcfe999..e098ec8b6 100644 --- a/site/demo/history.ts +++ b/site/demo/history.ts @@ -28,7 +28,7 @@ function cells(text: string): Cell[] { export const DEMO_LOG_LINES = [ "release $ demo logs", - "Fictional checkout service logs. Search ERROR, WARN or ready; drag the scrollbar to browse.", + "Fictional checkout service logs. Search ERROR, WARN or ready; scroll to browse.", ...Array.from({ length: 180 }, (_, index) => { const n = index + 1; const time = `10:${String(Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}`; diff --git a/site/demo/transport.ts b/site/demo/transport.ts index 437e191ca..6451fd1e0 100644 --- a/site/demo/transport.ts +++ b/site/demo/transport.ts @@ -12,7 +12,8 @@ * one notice instead of a TUI. A message sent from a chat gets a demo answer. * What does not: files, images, push and remote PCs, which need a real machine. */ -import type { AgentIntegration, AgentStatus, ConversationTurn, IntegrationsResponse, Machine, MachineEvent, PaneDirection, PaneFindMatch, PaneFindRequest, PaneMoved, PanePlan, PaneResized, PaneSplit, PaneSwapped, PaneZoomed, PendingMessage, PlanStep, PluginActionResult, PluginActions, PluginActionsResponse, PortalStatus, ServerMessage, SessionSnapshot, TabMoved, UsageReport, WorkspaceCreated, WorkspaceInfo, WorktreeEntry, WorktreeListing, WorktreeOpened, WorktreeRemoved } from "../../shared/protocol.ts"; +import type { AgentIntegration, AgentStatus, ConversationTurn, IntegrationsResponse, Machine, MachineEvent, PaneDirection, PaneFindMatch, PaneFindRequest, PanePlan, PaneResized, PaneSplit, PaneSwapped, PaneZoomed, PendingMessage, PlanStep, PluginActionResult, PluginActions, PluginActionsResponse, PortalStatus, ServerMessage, SessionSnapshot, TabMoved, UsageReport, WorkspaceCreated, WorkspaceInfo, WorktreeEntry, WorktreeListing, WorktreeOpened, WorktreeRemoved } from "../../shared/protocol.ts"; +import type { RepoDiffFile, RepoDiffFileList, RepoDiffFilePatch, RepoDiffRepository, RepoDiffScope } from "../../shared/protocol.ts"; import { VOICE_DEFAULTS, type VoiceStatus } from "../../shared/voice.ts"; import { isAgentName } from "../../shared/agent-name.ts"; import { neighborPane } from "../../src/lib/layoutMap.ts"; @@ -30,6 +31,7 @@ const DEMO_VERSION = "demo"; /** Only an explicit `demo logs` command starts this fictional retained history. */ const histories = new Map(); const terminalGrids = new Map(); +const heldCopyResends = new Map; at: number }>(); const demoGrid = (paneId: string) => terminalGrids.get(paneId) ?? { cols: 80, rows: 24 }; function redrawHistory(paneId: string): void { for (const socket of sockets) socket.renderHistory(paneId); @@ -46,8 +48,6 @@ const integrationsFixture: AgentIntegration[] = [ ].map(([target, command, available, state]) => ({ target: target as string, label: (target as string).replace("_", "-"), command: command as string, available: available as boolean, state: state as string })); type Pane = SessionSnapshot["panes"][number]; -type Tab = SessionSnapshot["tabs"][number]; -type Layout = SessionSnapshot["layouts"][number]; type SseListener = (event: MessageEvent) => void; // ---- state ----------------------------------------------------------------------------------- @@ -242,152 +242,6 @@ function relabelAutoTabs(workspaceId: string): void { } } -function recountDemoWorkspace(workspaceId: string): void { - const snap = snapshot(); - const workspace = snap.workspaces.find((candidate) => candidate.workspace_id === workspaceId); - if (!workspace) return; - const panes = snap.panes.filter((pane) => pane.workspace_id === workspaceId); - const tabs = snap.tabs.filter((tab) => tab.workspace_id === workspaceId); - for (const tab of tabs) { - const own = panes.filter((pane) => pane.tab_id === tab.tab_id); - tab.pane_count = own.length; - tab.agent_status = rollupStatus(own.map((pane) => pane.agent_status)); - } - workspace.pane_count = panes.length; - workspace.tab_count = tabs.length; - workspace.agent_status = rollupStatus(panes.map((pane) => pane.agent_status)); - if (!tabs.some((tab) => tab.tab_id === workspace.active_tab_id)) workspace.active_tab_id = tabs[0]?.tab_id ?? workspace.active_tab_id; -} - -/** a tab's layout as herdr reports one: the fixture's where it has one, else its panes side by side */ -function demoLayout(tabId: string): Layout { - const snap = snapshot(); - const existing = snap.layouts.find((layout) => layout.tab_id === tabId); - if (existing) return existing; - const own = snap.panes.filter((pane) => pane.tab_id === tabId); - const width = Math.floor(120 / Math.max(1, own.length)); - return { - workspace_id: own[0]?.workspace_id ?? "", tab_id: tabId, zoomed: false, area: { x: 0, y: 0, width: 120, height: 40 }, - focused_pane_id: own.find((pane) => pane.focused)?.pane_id ?? own[0]?.pane_id ?? "", - panes: own.map((pane, index) => ({ pane_id: pane.pane_id, focused: pane.focused, rect: { x: index * width, y: 0, width, height: 40 } })), - splits: [], - }; -} - -// herdr's `pane move`: the pane into another tab, a new tab or a new workspace, under a new id -// when it leaves its workspace; the tab and workspace it emptied close behind it -function moveDemoPane(pane: Pane, destination: Record, focus = false): PaneMoved | Response { - const snap = snapshot(); - const previous = { pane_id: pane.pane_id, workspace_id: pane.workspace_id, tab_id: pane.tab_id }; - const makeTab = (workspaceId: string, label: string): Tab => { - const number = Math.max(0, ...snap.tabs.filter((tab) => tab.workspace_id === workspaceId).map((tab) => tab.number)) + 1; - const tab: Tab = { ...structuredClone(newTabTemplate), tab_id: `${workspaceId}:t${number}`, workspace_id: workspaceId, label, number, agent_status: pane.agent_status, focused: false, pane_count: 0 }; - snap.tabs.push(tab); - if (label === "") autoTabs.add(tab.tab_id); else autoTabs.delete(tab.tab_id); - return tab; - }; - let targetTab: Tab; - let createdTab: Tab | null = null; - let createdWorkspace: WorkspaceInfo | null = null; - switch (destination["type"]) { - case "tab": { - const tab = snap.tabs.find((candidate) => candidate.tab_id === destination["tab_id"]); - if (!tab) return error("tab_not_found", "no such tab", 404); - if (tab.tab_id === pane.tab_id) { - return { changed: false, reason: "same_tab", previous_pane_id: pane.pane_id, previous_workspace_id: pane.workspace_id, previous_tab_id: pane.tab_id, pane, focused_pane_id: snap.focused_pane_id ?? pane.pane_id, target_layout: demoLayout(tab.tab_id) }; - } - targetTab = tab; - break; - } - case "new_tab": { - const workspace = snap.workspaces.find((candidate) => candidate.workspace_id === (destination["workspace_id"] ?? pane.workspace_id)); - if (!workspace) return error("workspace_not_found", "no such workspace", 404); - targetTab = createdTab = makeTab(workspace.workspace_id, String(destination["label"] ?? "")); - break; - } - case "new_workspace": { - const id = `w${(nextWorkspace++).toString(36)}`; - const cwd = pane.cwd ?? pane.foreground_cwd ?? "/home/demo"; - const label = String(destination["label"] ?? "") || cwd.split("/").pop() || id; - createdWorkspace = { ...structuredClone(newWorkspaceTemplate), workspace_id: id, label, number: snap.workspaces.length + 1, active_tab_id: `${id}:t1`, agent_status: pane.agent_status, focused: false, pane_count: 0, tab_count: 1, worktree: worktreeMetadata(repositoryAt(cwd)) }; - snap.workspaces.push(createdWorkspace); - targetTab = createdTab = makeTab(id, String(destination["tab_label"] ?? "")); - break; - } - default: - return error("invalid_destination", "destination.type must be \"tab\", \"new_tab\" or \"new_workspace\"", 400); - } - const crossing = targetTab.workspace_id !== previous.workspace_id; - const paneId = crossing ? `${targetTab.workspace_id}:p${(nextWorkspace++).toString(36)}` : pane.pane_id; - const target = snap.layouts.find((layout) => layout.tab_id === targetTab.tab_id); - if (target) { - const beside = target.panes.find((candidate) => candidate.pane_id === (destination["target_pane_id"] ?? target.focused_pane_id)) ?? target.panes.at(-1); - if (!beside || !splitLayout(target, beside.pane_id, { pane_id: paneId, focused: false, rect: beside.rect }, destination["split"] === "down" ? "down" : "right")) { - return error("split_failed", "The demo pane is too small to split.", 400); - } - } else addDemoLayout(targetTab.workspace_id, targetTab.tab_id, paneId); - if (crossing) { - movedPaneIds.set(previous.pane_id, paneId); - const key = keyOfPane.get(previous.pane_id); - if (key !== undefined) { keyOfPane.delete(previous.pane_id); keyOfPane.set(paneId, key); } - if (replying.delete(previous.pane_id)) replying.add(paneId); - for (const agent of snap.agents) if (agent.pane_id === previous.pane_id) agent.pane_id = paneId; - for (const socket of sockets) socket.holdPending(previous.pane_id, "pane_not_found", "This demo pane moved to another workspace. Copy the message before discarding it."); - } - pane.pane_id = paneId; - pane.workspace_id = targetTab.workspace_id; - pane.tab_id = targetTab.tab_id; - const source = snap.layouts.find((layout) => layout.tab_id === previous.tab_id); - if (source) { - closeLayoutPane(source, previous.pane_id); - if (source.focused_pane_id === previous.pane_id) source.focused_pane_id = source.panes[0]?.pane_id ?? ""; - for (const candidate of source.panes) candidate.focused = candidate.pane_id === source.focused_pane_id; - } - let closedTabId: string | null = null; - let closedWorkspaceId: string | null = null; - if (!snap.panes.some((candidate) => candidate.tab_id === previous.tab_id)) { - closedTabId = previous.tab_id; - snap.tabs = snap.tabs.filter((tab) => tab.tab_id !== previous.tab_id); - autoTabs.delete(previous.tab_id); - snap.layouts = snap.layouts.filter((layout) => layout.tab_id !== previous.tab_id); - if (!snap.tabs.some((tab) => tab.workspace_id === previous.workspace_id)) { - closedWorkspaceId = previous.workspace_id; - snap.workspaces = snap.workspaces.filter((workspace) => workspace.workspace_id !== previous.workspace_id); - } - } - relabelAutoTabs(previous.workspace_id); - relabelAutoTabs(targetTab.workspace_id); - recountDemoWorkspace(previous.workspace_id); - recountDemoWorkspace(targetTab.workspace_id); - if (focus) { - snap.focused_pane_id = paneId; - snap.focused_tab_id = targetTab.tab_id; - snap.focused_workspace_id = targetTab.workspace_id; - } else { - // focus was not asked to follow, but it cannot stay on a tab or workspace that closed behind - // the pane: a pane still there takes it, one outside the destination when there is one - if (snap.focused_pane_id === previous.pane_id || !snap.tabs.some((tab) => tab.tab_id === snap.focused_tab_id) || !snap.workspaces.some((workspace) => workspace.workspace_id === snap.focused_workspace_id)) { - const heir = snap.panes.find((candidate) => candidate.pane_id === source?.focused_pane_id) - ?? snap.panes.find((candidate) => candidate.workspace_id === previous.workspace_id && candidate.pane_id !== paneId) - ?? snap.panes.find((candidate) => candidate.workspace_id !== targetTab.workspace_id) - ?? pane; - snap.focused_pane_id = heir.pane_id; - snap.focused_tab_id = heir.tab_id; - snap.focused_workspace_id = heir.workspace_id; - } - } - for (const candidate of snap.panes) candidate.focused = candidate.pane_id === snap.focused_pane_id; - for (const tab of snap.tabs) tab.focused = tab.tab_id === snap.focused_tab_id; - snap.workspaces.forEach((workspace, index) => { workspace.number = index + 1; workspace.focused = workspace.workspace_id === snap.focused_workspace_id; }); - const focused = snap.panes.find((candidate) => candidate.pane_id === snap.focused_pane_id); - if (focused) focusDemoPane(focused); - return { - changed: true, previous_pane_id: previous.pane_id, previous_workspace_id: previous.workspace_id, previous_tab_id: previous.tab_id, pane, - created_tab: createdTab, created_workspace: createdWorkspace, closed_tab_id: closedTabId, closed_workspace_id: closedWorkspaceId, - focused_pane_id: snap.focused_pane_id ?? paneId, target_layout: demoLayout(targetTab.tab_id), - }; -} - function emitSse(event: MachineEvent): void { const message = new MessageEvent("message", { data: JSON.stringify(event) }); for (const listener of sseListeners) listener(message); @@ -448,12 +302,8 @@ function agentOf(paneId: string): string { const now = () => new Date().toISOString(); -/** the id a pane answers to after a move took it to another workspace, for a mock turn begun under the old one */ -const movedPaneIds = new Map(); - /** The bridge takes one accepted pending message when the current mock turn finishes. */ function finishDemoTurn(paneId: string, status: "idle" | "done" = "done"): void { - while (movedPaneIds.has(paneId)) paneId = movedPaneIds.get(paneId)!; replying.delete(paneId); if (!paneOf(paneId)) return; setStatus(paneId, status); @@ -525,6 +375,54 @@ async function layoutBodyOf(method: string, init: RequestInit | undefined, input const DEMO_FILES = ["src/routes/payments.ts", "src/lib/idempotency.ts", "src/metrics.ts", "src/pages/Reports.tsx", "src/money.ts", "src/money.test.ts", "package.json", "README.md"]; +/** + * The demo repository's uncommitted work, as GET /api/workspace/diff/* answers it: one file of + * each kind a diff view has to draw. The patches are what git would print, hunk counts included. + */ +const DEMO_DIFF_LIMITS = { max_files: 2000, max_list_bytes: 2 * 1024 * 1024, max_patch_bytes: 1024 * 1024, context_lines: 3 }; +const DEMO_DIFF: { file: RepoDiffFile; patch: string | null }[] = [ + { file: { path: "docs/checkout-flow.png", old_path: null, status: "modified", additions: null, deletions: null, binary: true }, patch: null }, + { file: { path: "src/lib/idempotency.ts", old_path: "src/idempotency.ts", status: "renamed", additions: 0, deletions: 0, binary: false }, patch: [ + "diff --git a/src/idempotency.ts b/src/lib/idempotency.ts", + "similarity index 100%", + "rename from src/idempotency.ts", + "rename to src/lib/idempotency.ts", + "", + ].join("\n") }, + { file: { path: "src/money.test.ts", old_path: null, status: "untracked", additions: null, deletions: null, binary: false }, patch: [ + "diff --git a/src/money.test.ts b/src/money.test.ts", + "new file mode 100644", + "index 0000000..5d2e8c1", + "--- /dev/null", + "+++ b/src/money.test.ts", + "@@ -0,0 +1,6 @@", + "+import { expect, it } from \"bun:test\";", + "+import { toCents } from \"./money.ts\";", + "+", + "+it(\"rounds half a cent away from zero\", () => {", + "+ expect(toCents(10.075)).toBe(1008);", + "+});", + "", + ].join("\n") }, + { file: { path: "src/money.ts", old_path: null, status: "modified", additions: 4, deletions: 1, binary: false }, patch: [ + "diff --git a/src/money.ts b/src/money.ts", + "index 3f1c2aa..9b7d410 100644", + "--- a/src/money.ts", + "+++ b/src/money.ts", + "@@ -1,5 +1,8 @@", + " export type Cents = number;", + " ", + "+/** Rounds half away from zero, so 10.075 is 1008 cents and not 1007. */", + " export function toCents(amount: number): Cents {", + "- return amount * 100;", + "+ const scaled = Math.abs(amount) * 100;", + "+ const rounded = Math.round(scaled + Number.EPSILON * scaled);", + "+ return Math.sign(amount) * rounded;", + " }", + "", + ].join("\n") }, +]; + /** Settings → Subscription usage on the demo: plans as the workstation's CLIs might report them, resets counted from now. */ function usageReport(): UsageReport { const now = Date.now(); @@ -835,19 +733,6 @@ async function route(url: URL, method: string, init: RequestInit | undefined, in structureChanged(); return json({ ok: true }); } - if (path === "/api/pane/move") { - const body = await bodyOf(init, input); - const pane = paneOf(String(body["pane_id"] ?? "")); - if (!pane) return error("pane_not_found", "source pane not found", 404); - const destination = body["destination"]; - if (!destination || typeof destination !== "object" || Array.isArray(destination)) return error("invalid_destination", "destination must be an object", 400); - const focus = body["focus"]; - if (focus !== undefined && typeof focus !== "boolean") return error("invalid_focus", "focus must be a boolean", 400); - const moved = moveDemoPane(pane, destination as Record, focus === true); - if (moved instanceof Response) return moved; - structureChanged(); - return json(moved satisfies PaneMoved); - } if (path === "/api/layout/ratio") { const body = await layoutBodyOf(method, init, input); if (body instanceof Response) return body; @@ -1050,9 +935,10 @@ async function route(url: URL, method: string, init: RequestInit | undefined, in const pane: Pane = { ...structuredClone(template), pane_id: `${id}:p${(nextWorkspace++).toString(36)}`, tab_id: tabId, terminal_id: `${id}:term${number}`, workspace_id: id, label: null, title: null, agent, agent_session: null, agent_status: agent ? "working" : "unknown", cwd, foreground_cwd: cwd, focused: false, terminal_title: null, terminal_title_stripped: null, revision: 1 }; snap.panes.push(pane); addAgent(pane); - snap.tabs.push({ ...structuredClone(snap.tabs[0]!), tab_id: tabId, workspace_id: id, label: String(body["label"] ?? ""), number, agent_status: pane.agent_status, focused: false, pane_count: 1 }); + const label = String(body["label"] ?? ""); + snap.tabs.push({ ...structuredClone(snap.tabs[0]!), tab_id: tabId, workspace_id: id, label, number, agent_status: pane.agent_status, focused: false, pane_count: 1 }); addDemoLayout(id, tabId, pane.pane_id); - if (String(body["label"] ?? "") === "") autoTabs.add(tabId); + if (label === "") autoTabs.add(tabId); else autoTabs.delete(tabId); relabelAutoTabs(id); workspace.tab_count = tabs.length + 1; @@ -1070,6 +956,24 @@ async function route(url: URL, method: string, init: RequestInit | undefined, in if (path === "/api/voice") return json({ configured: false, source: null, ...VOICE_DEFAULTS } satisfies VoiceStatus, 200, { "cache-control": "no-store" }); if (path === "/api/voice/config") return error("demo", "the demo saves no OpenAI key", 409); if (path === "/api/voice/transcribe") return error("voice_not_configured", "transcription is unavailable in the demo", 409); + if (path === "/api/workspace/diff/files" || path === "/api/workspace/diff/file") { + const scope = query.get("scope") || "workspace"; + if (scope !== "workspace" && scope !== "staged" && scope !== "commit") return error("invalid_scope", "scope must be workspace, staged or commit", 400); + // the demo keeps no history to name commits in + if (scope === "commit") return error("revision_not_found", "the demo repository has no commits to compare", 404); + const root = snapshot().panes.find((pane) => pane.pane_id === paneId)?.cwd ?? query.get("directory") ?? "/home/demo/checkout-api"; + const repository: RepoDiffRepository = { root, branch: "main", head: "4f2a9c1d7e3b5a8c6d0e1f2a3b4c5d6e7f8a9b0c", detached: false, unborn: false }; + // everything in the demo is unstaged work + const changed = scope === "workspace" ? DEMO_DIFF : []; + const compared: RepoDiffScope = scope; + const shared = { repository, scope: compared, base: null, head: null }; + if (path === "/api/workspace/diff/files") { + return json({ ...shared, files: changed.map((entry) => entry.file), total: changed.length, truncated: false, limits: { max_files: DEMO_DIFF_LIMITS.max_files, max_list_bytes: DEMO_DIFF_LIMITS.max_list_bytes } } satisfies RepoDiffFileList); + } + const entry = changed.find((candidate) => candidate.file.path === query.get("path")); + if (!entry) return error("not_found", "nothing changed at that path in this comparison", 404); + return json({ ...shared, file: entry.file, patch: entry.patch, unavailable: entry.patch === null ? "binary" : null, truncated: false, limits: { max_patch_bytes: DEMO_DIFF_LIMITS.max_patch_bytes, context_lines: DEMO_DIFF_LIMITS.context_lines } } satisfies RepoDiffFilePatch); + } if (path.startsWith("/api/fs/")) return error("not_found", "the demo has no files to open", 404); return error("demo", `${method} ${path} is not part of the demo`, 404); } @@ -1184,7 +1088,7 @@ class DemoSocket extends EventTarget { const open = new Event("open"); this.onopen?.(open); this.dispatchEvent(open); - this.push({ type: "snapshot", snapshot: snapshot(), features: ["submit", "pending-input", "pending-hold", "secret-input", "input-ready", "conversation-watch"] }); + this.push({ type: "snapshot", snapshot: snapshot(), features: ["submit", "pending-input", "pending-hold", "secret-input", "input-ready", "conversation-watch", "ping"] }); }, 20); this.timers.add(opening); } @@ -1217,10 +1121,10 @@ class DemoSocket extends EventTarget { send(raw: string): void { if (this.readyState !== 1) return; - let message: { type: string; pane_id?: string; cols?: number; rows?: number; text?: string; payload?: string; keys?: string[]; id?: number; mode?: string; typed?: unknown; delivery?: unknown; clear_draft?: unknown; pending_id?: string; action?: unknown; enabled?: boolean }; + let message: { type: string; pane_id?: string; cols?: number; rows?: number; text?: string; payload?: string; keys?: string[]; id?: number; mode?: string; typed?: unknown; delivery?: unknown; clear_draft?: unknown; send_anyway?: unknown; resend_id?: unknown; resend_generation?: unknown; pending_id?: string; action?: unknown; enabled?: boolean; stop?: unknown }; try { message = JSON.parse(raw); } catch { return; } if ((message.type === "submit" || message.type === "pending-action") && Number.isSafeInteger(message.id)) { - const signature = JSON.stringify({ type: message.type, pane_id: message.pane_id, text: message.text, payload: message.payload, typed: message.typed, delivery: message.delivery, clear_draft: message.clear_draft, pending_id: message.pending_id, action: message.action }); + const signature = JSON.stringify({ type: message.type, pane_id: message.pane_id, text: message.text, payload: message.payload, typed: message.typed, delivery: message.delivery, clear_draft: message.clear_draft, send_anyway: message.send_anyway, resend_id: message.resend_id, resend_generation: message.resend_generation, pending_id: message.pending_id, action: message.action }); const previous = this.requests.get(message.id!); if (previous) { if (previous.signature !== signature) { @@ -1256,12 +1160,19 @@ class DemoSocket extends EventTarget { case "presence": // the demo sends no alerts, so whether the page is in use holds nothing back break; + // the demo socket never goes half-open, but it answers the liveness probe as the server does + case "ping": + if (Number.isSafeInteger(message.id)) this.push({ type: "pong", id: message.id! }); + break; case "attach": if (message.pane_id) { this.resizeGrid(message.pane_id, message.cols, message.rows); this.attach(message.pane_id); } break; case "resize": if (message.pane_id && this.attached.has(message.pane_id) && this.mode === "interact") this.resizeGrid(message.pane_id, message.cols, message.rows); break; + // the demo page is its pane's only terminal: no other grid to hand the pty to, and the server answers nothing + case "release-size": + break; // The demo has no competing attach slots and does not advertise this capability. case "take-over": this.push({ type: "error", code: "unsupported", message: "The demo has no competing terminal attachments.", pane_id: message.pane_id }); @@ -1289,6 +1200,7 @@ class DemoSocket extends EventTarget { this.conversationInterests.delete(message.pane_id); } break; + // a Stop (`stop`) is typed like any key: the demo has no pty for a following message to overtake case "input": if (message.pane_id && message.text !== undefined) this.typed(message.pane_id, message.text); break; case "keys": if (message.pane_id) for (const key of message.keys ?? []) this.typed(message.pane_id, chordText(key)); break; case "secret": @@ -1300,18 +1212,38 @@ class DemoSocket extends EventTarget { break; } if (message.pane_id && message.text !== undefined && message.id !== undefined) { + if (message.resend_id !== undefined && message.send_anyway !== undefined) { + this.completeRequest({ type: "submit-result", id: message.id, pane_id: message.pane_id, ok: false, code: "invalid_delivery", message: "Use a held-copy resend id only for an immediate chat message" }); + break; + } const queued = message.delivery === "queue" || message.delivery === "hold"; + for (const [key, receipt] of heldCopyResends) if (Date.now() - receipt.at >= 60_000) heldCopyResends.delete(key); + const resendKey = message.resend_id === undefined ? null : JSON.stringify([message.pane_id, message.resend_id, message.resend_generation ?? 0]); + const previous = resendKey === null ? undefined : heldCopyResends.get(resendKey); + if (previous && this.mode !== "observe" && message.clear_draft === undefined) { + this.push(previous.text === message.text ? { ...previous.reply, id: message.id } + : { type: "submit-result", id: message.id, pane_id: message.pane_id, ok: false, code: "invalid_submit_id", message: "This resend id already belongs to another message." }); + break; + } const text = queued ? message.text.replace(/[\r\n]+$/, "").replace(/\r\n?/g, "\n") : message.text; let code: string | null = null; if ((message.delivery !== undefined && message.delivery !== "immediate" && !queued) || (queued && message.typed !== undefined && message.typed !== false)) code = "invalid_delivery"; else if (message.clear_draft !== undefined && (typeof message.clear_draft !== "string" || !message.clear_draft || queued || (message.typed !== undefined && message.typed !== false))) code = "invalid_delivery"; + else if (message.send_anyway !== undefined && (message.send_anyway !== true || queued || message.clear_draft !== undefined + || (message.typed !== undefined && message.typed !== false))) code = "invalid_delivery"; else if (this.mode === "observe") code = "read_only"; - else if (message.clear_draft !== undefined) code = "unsupported"; + // the demo agent never refuses over its input box, so there is nothing to clear or send over + else if (message.clear_draft !== undefined || message.send_anyway !== undefined) code = "unsupported"; + else if (message.resend_id !== undefined && (typeof message.resend_id !== "string" || !message.resend_id || message.resend_id.length > 128 || queued || message.typed === true)) code = "invalid_delivery"; + else if (message.resend_generation !== undefined && (typeof message.resend_generation !== "number" || !Number.isSafeInteger(message.resend_generation) || message.resend_generation < 0 || message.resend_id === undefined)) code = "invalid_delivery"; + else if (resendKey !== null && heldCopyResends.size >= 256) code = "pending_limit"; else if (queued && (text.trim().length === 0 || text.length > 20_000 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/u.test(text))) code = "invalid_submit_text"; else if (queued) code = this.pendingTargetError(message.pane_id); if (code) { - this.completeRequest({ type: "submit-result", id: message.id, pane_id: message.pane_id, ok: false, code, message: "The demo agent cannot take this submission." }); + const reply: Extract = { type: "submit-result", id: message.id, pane_id: message.pane_id, ok: false, code, message: "The demo agent cannot take this submission." }; + if (resendKey !== null && this.mode !== "observe" && code !== "pending_limit" && !previous) heldCopyResends.set(resendKey, { text: message.text, reply, at: Date.now() }); + this.completeRequest(reply); break; } if (queued && (message.delivery === "hold" || paneOf(message.pane_id)?.agent_status === "working")) { @@ -1323,7 +1255,9 @@ class DemoSocket extends EventTarget { this.push(reply); } else { submitToChat(message.pane_id, text); - this.completeRequest({ type: "submit-result", id: message.id, pane_id: message.pane_id, ok: true }); + const reply: Extract = { type: "submit-result", id: message.id, pane_id: message.pane_id, ok: true }; + if (resendKey !== null) heldCopyResends.set(resendKey, { text: message.text, reply, at: Date.now() }); + this.completeRequest(reply); } } break; @@ -1331,6 +1265,9 @@ class DemoSocket extends EventTarget { if (message.id !== undefined && message.pane_id && message.pending_id) { const result = (ok: boolean, code?: string) => this.completeRequest({ type: "pending-result", id: message.id!, pane_id: message.pane_id!, pending_id: message.pending_id!, ok, ...(code ? { code, message: "The demo pending message was not changed." } : {}) }); if (message.action !== "steer" && message.action !== "discard") { result(false, "invalid_pending_action"); break; } + if (message.resend_generation !== undefined && (typeof message.resend_generation !== "number" || !Number.isSafeInteger(message.resend_generation) || message.resend_generation < 0)) { + result(false, "invalid_pending_action"); break; + } if (this.mode === "observe") { result(false, "read_only"); break; } const pending = this.pending.get(message.pane_id)?.find((item) => item.id === message.pending_id); if (!pending) { @@ -1346,11 +1283,27 @@ class DemoSocket extends EventTarget { this.removePending(message.pane_id, pending.id, "discarded"); result(true); } else { + const key = JSON.stringify([message.pane_id, pending.id, message.resend_generation ?? 0]); + for (const [id, receipt] of heldCopyResends) if (Date.now() - receipt.at >= 60_000) heldCopyResends.delete(id); + const previous = heldCopyResends.get(key); + if (previous) { + if (previous.text !== pending.text) { result(false, "invalid_submit_id"); break; } + if (previous.reply.ok) this.removePending(message.pane_id, pending.id, "sent"); + result(previous.reply.ok, previous.reply.code); + break; + } + if (heldCopyResends.size >= 256) { result(false, "pending_limit"); break; } + const resendResult = (ok: boolean, code?: string) => { + heldCopyResends.set(key, { text: pending.text, at: Date.now(), reply: { + type: "submit-result", id: message.id!, pane_id: message.pane_id!, ok, ...(code ? { code } : {}), + } }); + result(ok, code); + }; const code = this.pendingTargetError(message.pane_id); - if (code) { result(false, code); break; } + if (code) { resendResult(false, code); break; } if (pending.state === "uncertain") { result(false, "pending_uncertain"); break; } const sent = this.deliverPending(message.pane_id, pending); - result(sent, sent ? undefined : pending.error?.code ?? "pending_changed"); + resendResult(sent, sent ? undefined : pending.error?.code ?? "pending_changed"); } } break; diff --git a/src/AGENTS.md b/src/AGENTS.md index 5fef24dca..f099442f1 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -1,77 +1,6 @@ # src/ -React 18 + xterm.js browser client. The render-state machine, xterm lifecycle and mobile-keyboard handling live only here. - -## ANTI-PATTERNS -- Never persist the auth token client-side: the HttpOnly cookie is the only storage; no localStorage/sessionStorage anywhere. -- Key bar taps and Stop go through `term.input()`. PaneTerminal routes held-modifier chords and the plain arrow keys through WS `keys` so Herdr encodes the target pane's keyboard protocol and cursor-key mode (the attach stream never passes DECCKM on to xterm); other input uses WS `input`. A left press let go in its own cell with nothing selected is replayed to xterm as a click for the program; a drag, or a press with the selection modifier held, stays a selection. Composer and TerminalInput text sends use `socket.submit()` (WS `submit` when advertised, else `input` + `\r`); their text is never modified by held keys. Prompt-card answers use `/api/pane/prompt/answer`. -- Never hardcode a color in component CSS: the light theme is only token overrides. -- `term.reset()` fires only in the paneId effect, never mid-stream. -- Output ACKs run in xterm's write callback and capture the connection and pane subscription; never queue them across reconnects or pane switches. A 4008 overload close stops automatic reconnect and shows a notice; selecting another pane explicitly reconnects. Normal reconnect replays stored role/attach/geometry once, without a second control-frame queue. -- A WS receipt matches the request type, the captured pane and (for a pending action) the pending ID on its own connection, never an ID alone. A submit unanswered for 90 s gives up and keeps the text in the box; the client never sends it again. -- SecretInput keeps the secret in the DOM input only: never React state, a draft, a queue or a retry copy. It clears on submit, cancel, unmount, `pagehide` and a hidden document. -- Dictation (lib/voice.ts) only hands text to the caller to insert at the caret; it never sends. -- An alert chime before the first tap or key (`unlockAlertSound`) is skipped, never queued to sound late. -- Never import KaTeX statically: Markdown.tsx loads lib/katex.ts with the first expression (it is a fifth of the app's script). -- Never import Mermaid statically: Markdown.tsx loads lib/mermaid.ts with the first ```mermaid fence (its own 685 kB chunk). It keeps `securityLevel: "strict"`; a block that does not parse stays a code block. -- Markdown draws images only under a `MarkdownImageContext` (components/Markdown.tsx); FileViewer provides it for .md files through lib/markdownImages.ts, so a path reads through `/api/fs/file`. Chat has no provider: the image stays the `!` + link it always was (the CSP allows own-origin images only). - -## REMOTE TARGET OWNERSHIP -- `MachineSessionsProvider` owns one WebSocket per visited PC while the app is unlocked. Pane mounts only own their attach/watch and renderer subscriptions: leaving a pane detaches without closing the PC connection or suspending pending proof. `MachineSession` alone publishes pending receipts and suspends their scope on real disconnect/close. Do not infer held state locally or replay input when a pane returns. -- `MachineContext` binds every pane/workspace API call to a PC; PaneTerminal and CommandPalette remount on machine changes. Never use a mutable global target for async work. -- Local storage keeps legacy keys for local panes; remote drafts, held queues, lenses and caches use `paneStorageId(machineId, paneId)`. Held messages require Send now, including after reconnect/reload. -- App consumes a combined machine roster over SSE with a periodic GET backstop. A local herdr failure must never hide remote PCs or Add PC (Settings → Remote PCs; the row does not wait for `/api/machines/settings`, only the bridge switch does); auth discovery uses health `scope=bridge`. - -## CONVENTIONS -- main.tsx owns the side-effect imports: `./styles.css` and `./lib/viewport.ts` load there, never inside components. `./styles.css` is imported BEFORE `./App.tsx`: Vite emits CSS in import order, and a component stylesheet's single-class override of a primitive (`.sidebar-footer-action` over `.btn`) only wins when the primitive comes first (2026-09-21: the reversed order silently centered footer buttons and centered the palette). -- Component CSS is colocated (`X.css` next to `X.tsx`); new component styles go there, not into styles.css. Override primitives at the same specificity and rely on order, never on `!important` or selector stacking. -- Status surfaces (conn pill, banners, tree states) use `role="status"`; `role="alert"` is for error text (the App and sidebar error blocks, dialog and form errors, the prompt card's confirm). -- index.html owns document meta (`interactive-widget=resizes-content`, `theme-color`, manifest link); client code never re-declares it. -- A branch CSS alone cannot decide (a menu that is a bottom sheet or a popover, layout that has to know which side of a breakpoint it is on) reads `useMediaQuery` from `lib/useMediaQuery.ts`, never `matchMedia(...).matches` once at mount: the viewport moves while a menu is open. -- xterm runs Unicode version `15-herdr` (`matchHerdrWidths`, lib/terminalWidths.ts) so the cursor advances exactly as herdr laid out the screen; xterm's default Unicode 6 widths shift every line holding emoji. `adjustTerminalGlyphs` reworks the DOM renderer's spans after drawing; copy reads xterm's buffer, so it still returns what the pane wrote. -- xterm sizes every cell from the first matching font (lib/fontFamily.ts): "Symbols Nerd Font Mono" (private use area only) stays first, a proportional fallback stays behind `monospace`, a user's font list goes in front of the built-ins, never in place of them, and the bundled code face is named "JetBrains Mono Web" so it never claims the terminal's "JetBrains Mono". -- main.tsx imports fonts/fonts.css before styles.css. Pretendard's unicode-range chunks are cached by public/sw.js as the browser fetches them; never precache them (3 MB on every device). -- `src/motion.test.ts` scans every .css under src/ and allows one smooth endless animation, `bridge-progress-slide` (Machines.css). -- A phone's sheets (every `.modal-scrim` surface at ≤640px) rise and drop by CSS plus `watchSheetExits` (lib/sheets.ts, started in main.tsx), which puts a closed scrim's last frame back as an inert `.is-leaving` copy inside a closed shadow root (adopting the page's rules) until it has slid out, so no query of the page ever finds two of a sheet. A new sheet calls `useSheetSwipe(surface, close)` with the close its scrim runs, or null while it is busy, so a finger can drag it down. -- Settings are one localStorage record (`herdr-web-ui:settings`) that passes `sanitizeSettings` on load and on every update: a new setting needs its default and type check there, or it is dropped. Storage access can throw where it is blocked; keep it inside `try`. -- `history.state` may hold another script's data: read Settings entries only through `settingsEntry` (lib/settingsHistory.ts). -- Chat turns render inside `RenderBoundary` with `resetKey={turnRevision(turn, true)}` (a content hash cached for immutable API snapshots), because each poll parses every turn into new objects; one turn the chat cannot draw shows an inline notice instead of blanking the app. -- Snapshot reads in App go through `SnapshotRequests`: an event or a newer read supersedes an in-flight fetch, its error included. -- Chat path links (lib/filePaths.ts, ASCII `[\w@.+-]` names) and terminal path links (lib/terminalFileLinks.ts, Unicode letters) are separate matchers; a change to path recognition checks both. -- Helpers outside React call the module-level `t`; components take `useT()`, whose identity changes with the language, so a memo that formats text lists it. -- Global shortcut hints resolve through `shortcutDisplayKeys` with the current `shortcutOverrides`, never the defaults alone. Global bindings remain Mod+Shift; focus-local list/tab keys are separate and never intercept terminal input. The touch key bar sends terminal keys, not app actions. -- Work-block counts skip todo tools (`isTodoTool`: `TodoWrite` would match the write category). The Agents list never includes a shell just because it reports working or blocked. Pane-status pushes never trigger a worktree inventory poll. - -## WHERE TO LOOK -| Task | Location | Notes | -|------|----------|-------| -| Remote PCs UI | components/MachineSidebar.tsx + MachineDialog.tsx | server side is in server/AGENTS.md; docs/remote-pcs.md | -| App shell + render states | App.tsx | `locked === null` → connecting shell (no WebSocket yet); `locked` → AccessGate (pairing code, `?pair=CODE`, or token); else full shell. 5s health+session poll (`POLL_MS`) as backstop; pushes (`pane-status`/`pane-exited`/`session-changed`) merge instantly via `handleServerMessage` + a debounced refetch; a selection that left the snapshot falls back to herdr's focus; `view` (chat/terminal) is per-pane state (`herdr-web-ui:view:`; an agent pane opens in chat on a touch screen, else the terminal, unless Settings says otherwise); builds the `AppActions` object (lib/actions.ts) the header, sidebar, palette, settings and shortcuts all call; mounts NewSessionDialog / SettingsDialog / CommandPalette; PaneCanvas renders all panes of the selected tab with stable pane keys and per-pane file-path context | -| Split canvas | components/PaneCanvas.tsx + lib/paneCanvas.ts | authoritative herdr rectangles, including DAG sibling panes; stable per-pane mounts keep input leases/drafts during split and zoom; left click focuses, right click targets without focusing; pane menu and pointer-based RowMenu follow TUI scope/order; native split paths drive serialized boundary drags, canceled on tab/topology changes. Up to 768px (`SOLO_PANE_QUERY`) `soloPane` shows the selected pane alone, siblings mounted and hidden, without touching herdr's zoom; the tab's menu then lists the tab's panes. TabStrip is always shown and only manages tabs; desktop uses right-click/Shift+F10, touch or narrow layouts expose the active tab’s ⋯, and no inline close button is rendered. `useTabReorder` keeps snapshot order (tab numbers are stable IDs), previews a drop boundary before removal, and permits one move until herdr acknowledges it; drag cancellation never queues a later move. Touch menus and focus-local Alt+Shift+Left/Right share adjacent moves. | -| Terminal view/lifecycle | components/PaneTerminal.tsx + TerminalViewportTools.tsx + lib/terminalViewport.ts | one xterm per pane mount; `term.reset()` + re-attach on paneId change; `active` gates every automatic focus (including async uploads and prompt answers), and inactive siblings never take focus; theme + font size applied through `term.options` (no remount) and re-fit; the grid re-fits when the lens returns to terminal; ended / reconnecting pills only under the terminal lens (ChatView says them inline); viewport tools draw only native ranges validated against xterm's current cells, never reconstructed history. Automatic `jump:false` searches require an explicit response carrying `matches` and `scroll`, and no read-only/legacy bridge gets background search POSTs. The separate scrollbar calls herdr's shared `pane.scroll`; xterm remains at zero history. | -| Roles / observe mode | components/PaneTerminal.tsx | NO UI control — App connects as `interact` and never asks for another role; `role-ack` still applies the consequences if one arrives: stdin gate (`disableStdin`), `pane-geometry` adoption, force-refit + resize when returning to interact | -| Held-input draft | components/PaneTerminal.tsx + lib/draft.ts | input during a disconnect accumulates in a draft (printable only); the review banner offers Send/Discard after reconnect | -| Geometry/fit | components/PaneTerminal.tsx | fit on attach, ResizeObserver on the host, `focus`/`visibilitychange` refit, and a click or key in the terminal while another device's grid holds the pty (`reclaim`: a window that kept its focus gets no focus event) — all SKIPPED while observing; only a tab in use (`inUse`: visible AND `document.hasFocus()`) resizes the pty from the observer, the refit, `role-ack`, a font load or the lens return; out of use, a pane attach (a reload, the next pane after one closed) goes with `keep_size`, and blur or hidden marks `keepSize` so a background reconnect adopts the grid: a window left behind another app must not take the pane from a phone or herdr's own TUI; with `settings.releasePaneAway` on (off by default), after `RELEASE_AFTER_MS` out of use (from a blur, hidden, or a pane attach made out of use) it also detaches (`released`), and a touch screen (`coarse`) that goes hidden does so at once whatever the setting, since a phone's page can freeze before the timer runs, because herdr holds a pane at an attach's size until that attach ends and its TUI cannot take it back, and watches the pane instead (`socket.watch`, feature "watch": a read-only view at this grid that leaves the pane's size alone, written as it comes; a grid change starts it again; banner "View only…"); a server without the feature, or a `watch-end`, leaves it paused on its last screen. It attaches again on focus or a pointerdown (unwatch first); a released tab moving to another pane watches that one; it keeps the pane while a message it queued waits (`queueWaits`), and never releases a mirrored pane or an embedded page (the site's demo). `HerdrSocket.resize` sends nothing for a pane it is not attached to: the server would resize the holder's attach; single-finger touch drag becomes synthetic wheel events (listeners `passive: false` only there) | -| Key bar | components/KeyBar.tsx + KeyBarSettings.tsx + lib/keyBar.ts + lib/keys.ts | `settings.keyBarItems` owns the complete ordered row; absent legacy records migrate from `keyBarExtras`, explicit empty lists stay empty. Ordinary keys inherit held modifiers; configured combinations are exact. Taps go through `term.input()` into the guarded onData path. PaneTerminal owns sticky Ctrl/Alt/Shift, clearing state on pane/lens change, disconnect or modifier removal; hidden while observing | -| Chat composer | components/Composer.tsx + lib/compose.ts + lib/mentions.ts + lib/modelName.ts + PendingMessages.tsx | the model pill shows a name only for an id `modelLabel` matches whole, else the id as received in mono: never add a guessed name; Enter or Mod+Enter sends per `settings.enterSends`, IME Enter never sends; `/` commands cache 60s per pane, `@` files debounce 150ms; one Send/Stop control for every agent, no Queue button/menu; working Send uses `socket.submit` queue delivery only with `pending-input`, otherwise keeps the draft; pending rows offer an explicit Send now action on server IDs, retained uncertain text never retries; Stop is Escape via `onAbort`, also from the box's own Escape while a draft shows Send in its place | -| Chat lens | components/ChatView.tsx + lib/conversationRefresh.ts + Markdown.tsx (lib/markdown.ts) + lib/workBlocks.ts + lib/toolVerbs.ts + PromptCard.tsx | `splitTurn` folds tools/thinking/narration into one `.work-block` and honors explicit Codex commentary/final phases; a row is verb + object by exact tool id (`toolVerb`), the id kept in its title and opened detail; duration = `end_ts − ts`; only mounted visible Chat subscribes to `conversation-watch` on its attached machine-bound socket; `conversation-changed` uses a serial coordinator with push reads at least 2s apart, a visible-only 10s backstop (2s while `conversationPushes` is false: an older bridge or a released pane) and immediate initial/send/visibility/reconnect refresh; a visible scrollback stand-in is invalidated by the pane's `pty-data` through a ref listener (`onTerminalOutput`), never by React state per frame; newest and older pages share the coordinator; a send refresh preserves history/scroll, a pane change resets; ChatView polls `/api/pane/prompt` every 2s whatever the pane's status (a menu can wait while herdr reports idle: Codex's trust check, pi's dialogs, Claude Code's `/model` list) and owns the prompt; PromptCard answers through `/api/pane/prompt/answer` (409 → re-read) and is drawn through a portal into PaneTerminal's `.prompt-dock`, over the input card and outside the transcript | -| Sidebar roster | components/Sidebar.tsx + AgentSidebar.tsx + TabStrip.tsx; lib/useWorktreeBranches.ts + worktreeBranches.ts | workspace rows render inside `MachineSidebar`, one roster per PC; parent names stay `workspace.label`, opened linked worktree rows show branches from existing `GET /api/worktree/list` matched by repo key + open workspace ID + normalized checkout path, never labels or directory names; `Detached HEAD` requires the listing's flag; custom workspace names are secondary, while the default slash-to-dash name is suppressed, with full names/path in tooltips; create/open responses seed branch metadata and expand the parent; structural changes and visibility return refresh inventory, with a serial 30 s visible+online backstop and stale-request guards; status rolls up each workspace's panes in herdr's order, blocked > done > working > idle (`rollupStatus`, lib/status.ts), a folded worktree parent rolls up its whole group, and the row selects its current/last-viewed/focused pane; only linked worktree groups fold (`herdr-web-ui:worktree-group-collapsed::`), preserving the selected child and deliberate folds across status updates; a separate Agents list joins `snapshot.agents` to panes across the combined machine roster, with a recognized OmO pane fallback and pane status authoritative; every row sits on the `--side-*` grid declared on `.sidebar-shell` (leading glyph cell, title, trailing status cell); a workspace row leads with the mark of the agent in the pane it opens (`paneMark`, lib/sidebarAgents.ts), never another pane's, else the terminal or branch glyph; a top-level row starts with a folder, which is the fold (`.workspace-toggle`) when linked worktrees sit under it; the roster has no label or fold of its own, the caret beside the PC's `+` folds it; an agent row's line-two rules live in `agentContext` (lib/sidebarAgents.ts); in Workspaces order each workspace's agents on a PC sit in a hairline card (`groupRows`) whose head, plain text, names the place its rows' line leaves out (the tooltip keeps it; a row left with no line says its kind), pinned agents go above the cards, and Activity order draws no cards; agent rows only select their pane and have no secondary actions; the workspace row menu button takes no width at rest but stays focusable (RowMenu and ConfirmDialog return focus to it); both lists use existing SSE, never another status subscription or server contract; both read the status they draw, and the Agents list its Activity order, through `useSidebarActivity` (lib/sidebarActivity.tsx, provided by MachineSidebar; logic in lib/sidebarOrder.ts); workspace rows have no separate reorder grip; dragging the row or Alt+Up/Down on its selector uses `workspace.move`, optimistic and preserving herdr's repository/worktree hierarchy; workspace right-click and ⋯ use the same herdr workspace actions; pointer menus open without selecting; pane renaming stays on the canvas; tab/pane navigation stays in TabStrip and the palette; no search or filter here on purpose, the command palette is the search; Add PC lives in Settings → Remote PCs and the palette (`actions.openAddPc`) | -| Move pane to… | components/MovePaneMenu.tsx + lib/paneMove.ts; `actions.paneMoved` in App.tsx | the header More item opens a second RowMenu (popover, or the phone's sheet) listing `paneMoveTargets`: New tab, the workspace's other tabs, every other workspace, New workspace; a pick posts `/api/pane/move` once. A pane that changes workspace answers to a NEW id: `paneMoved` carries the lens and the two unsent drafts (`carryPaneRecords`; held queues and pending sends stay under the old id, they name a server lease) and selects the new id only when the moved pane was the selected one on that PC. A move made elsewhere (the TUI, another client) never moves the selection: the old id leaves the snapshot and App's fallback picks herdr's focus | -| New workspace / New tab | components/NewSessionDialog.tsx | `createWorkspace`, or `createTab` (POST /api/tab/create) when given a `tab` prop; agent start may take 60s and the dialog stays open while pending; `agent_started:false` + error offers to open the already-created workspace | -| Command palette | components/CommandPalette.tsx + lib/machinePalette.ts + lib/paletteSearch.ts | Mod+Shift+K; searches all connected PC snapshots by PC, pane, workspace, tab, branch and agent kind, groups by machine + workspace, and remembers `{machineId,paneId}` pairs (legacy per-PC recents migrate). Selection rechecks the target's live PC and pane; offline targets are omitted. Branch readers mount only while open, each under its owning `MachineContext`, and inventories require the current workspace signature. Status chips count connected PCs; app/plugin actions still use the selected PC/pane. `>` shows actions alone; filter letters act outside the search field, Left/Right step sections only from a row or the query's edge, and Escape closes. | -| Settings | components/SettingsDialog.tsx + SettingsControls.tsx + lib/settings.ts | `SettingsProvider` wraps App in main.tsx and writes `data-theme` / `data-density` on ``; the dialog shows one page at a time (`PAGES`; a phone lists them first) and mounts per opening, so a page's requests start when it is opened; a new setting is a `SettingsRow` in a `SettingsGroup` with one of the SettingsControls, never markup of its own; the dialog's accessible name stays Settings on every page, and a browser script reaches a page through `openSettingsPage` (scripts/settings-page.ts); each step in (dialog, a phone's page, key bar editor) is a history entry kept by lib/settingsHistory.ts, so open and close go through App's `settingsOpen` and the dialog's own state only, never a `history` call of their own; PhonePanel.tsx picks the address that works through lib/phone.ts `phonePlan`; QrCode.tsx is always dark-on-white | -| Shortcuts | lib/shortcuts.ts | `useShortcuts(actions, enabled)`: capture-phase window keydown; every global binding is Mod+Shift+key so the pty keeps Ctrl+; `SHORTCUTS` defines defaults and `shortcutDisplayKeys` resolves user-facing hints | -| OSC 52 clipboard | lib/osc52.ts + PaneTerminal `parser.registerOscHandler(52)` | client half of a clipboard bridge; DORMANT on herdr 0.9.x (herdr's screen-diff attach stream consumes OSC 52 before clients — live-verified); fires as a transient pill once sequences arrive | -| Sidebar badges | components/Sidebar.tsx `StatusBadge` + lib/status.ts | ONE vocabulary for every surface (sidebar, palette, composer status line): idle→READY, working→RUN, blocked→INPUT, done→DONE, unknown→`—` (`STATUS_WORD`, `knownStatus`), and a pane at rest with `background_wait`→BG (`paneStatus`, status `waiting`: the workspace row's rollup, the agent row, the palette, the tab dot and the composer's read-only word; send/queue/stop keep the raw status); the compact sidebar variant draws a glyph for RUN, INPUT, DONE and BG (RUN's arc, held still) and nothing for READY and unknown, but always renders the element with `data-status`, its label and a box (the browser scripts wait on it); background tasks are `.background-count`, not a `.badge`; the composer alone reads a blank state as READY (a shell is always ready); pushed statuses land through `applyPaneStatus` (lib/snapshot.ts) | -| Notifications | lib/notifications.ts | tab alerts: policy re-exported from shared/notify-policy.ts; hidden-tab guard; shown via `registration.showNotification` (Android has no `new Notification()`), constructor only as fallback; skipped when this device has push (`pushOnRef`) | -| Deep links | lib/deepLink.ts (pure) + App.tsx | the address names where the app is: `?machine&ws=-&pane&view&file&settings§ion&new&cwd&name&agent&sidebar` (`readLink`/`linkSearch`); App reads it once at load, settles it against the PC's roster once that is in (`resolveLink`: a slug another open workspace has wins over a reused id; a closed pane opens its workspace's row pane; a header note says what could not open), and writes it back from one effect: another pane or PC is `pushState` (Back returns to the last one), everything else `replaceState`; nothing is written while locked or before the roster is in; a pane picked before the link lands supersedes it; a link's lens is drawn as `notificationViewTarget`, never stored; Settings' own entries stay lib/settingsHistory.ts's, their address carrying `settings`/`section`; the New workspace dialog's fields reach the address 400 ms after they rest (Safari refuses bursts of history writes); a link never creates anything; a pane that mounts under an open modal does not take its focus (`modalOpen`, lib/focus.ts) | -| Shared settings and pins | lib/preferencesSync.ts (App, after the access gate) + `SETTING_SCOPE` in lib/settings.ts + lib/sidebarPins.ts store | every setting is `shared` or `device` in SETTING_SCOPE (the build fails on one that is neither; when in doubt, device); a server with a document gives it to a page on load, a load never writes; a change sends only the shared settings that changed (and the pins when they did) 600 ms after they rest, and the server lays them over what it holds, so a setting nobody changed stays each device's own; an event is ignored at or below the last revision seen, or while a change here is not yet sent (it goes out next and wins); a 403 (a watching device) stops sending and keeps taking what the others share; a 404 (an older server) turns sync off; the file reader (`?read=`) runs none of it | -| Web push | lib/push.ts + App Alerts item (header More menu) + public/sw.js | `ensurePushSubscription` (SINGLE-FLIGHT: overlapping callers share one attempt, because two concurrent `subscribe()` calls make Chrome issue two subscriptions and keep only the later; idempotent; replaces a subscription made for another server key; runs on every load when permission is granted) and `removePushSubscription` (lock, before signOut); the Alerts item says off / pushed / this tab / in the app (permission not granted; a tap asks while it is unanswered, else switches off), and the More button carries a dot while alerts are off here; SW posts `{type:"select-pane", view:"chat"}` on a tapped pane alert, a closed app opens `/?pane=&view=chat`, a deep link like any other (lib/deepLink.ts), and App draws that pane's chat over its own lens (`notificationViewTarget`, render only: never in `lens` or `herdr-web-ui:view:`) until the pane or a lens is picked by hand; a pane with no agent keeps its own lens, and the in-app alert card (Droplet) opens the pane's own lens | -| HTTP client | lib/api.ts | fetchSession / fetchHealth / authenticate / signOut; `ApiError` carries status + code | -| App updates | lib/updates.ts + components/UpdateControls.tsx | authenticated status polling; Settings check/install + header notice; compare build-time Git revision to running server, offer Reload app explicitly so unsent drafts survive bridge updates | -| WS client | lib/ws.ts | HerdrSocket; role replays before attaches on reconnect; input/keys are NEVER queued — the UI draft owns them | -| Mobile keyboard | lib/viewport.ts | publishes `visualViewport.height` as `--app-height` and pins scroll to (0,0); pairs with `interactive-widget=resizes-content` in index.html | -| PWA | pwa.ts | registers /sw.js in secure contexts only; a plain-http LAN IP runs uncached by design | -| Styling | styles.css + colocated .css | styles.css = `:root` tokens (+ `[data-theme="light"]`, `[data-density="compact"]`) + shell + shared primitives (`.icon-button .btn .pill .badge .segmented .kbd .modal .field .input .select .menu`); component looks live in their own .css; tokens only — no color literals | -| Icons | lucide-react | the one icon set; AgentMark.tsx keeps the provider brand marks | -| Workspace drawer / a11y | App.tsx + components/SidebarResizer.tsx + lib/sidebarWidth.ts | from 769px the sidebar's right edge resizes it (drag, arrows, Home/End, double-click resets); the width is per device (`herdr-web-ui:sidebar-width`) and reaches CSS as `--sidebar-user-w` on `.app`, read through `--sidebar-shown-w` by `.sidebar` and `.header-side` only, so dialogs sized from `--sidebar-w` do not move; toggle is `aria-expanded` + `aria-controls="workspace-drawer"`; scrim closes on tap and stays mounted while the drawer is closed (`.scrim.is-open`): a swipe begun on it closes the drawer, and unmounting it mid-stroke would take the stroke's `touchend` with it (lib/edgeSwipe.ts); conn pill and banners are live regions; the conn pill is drawn only while not live and stays in the document (`.conn-live`, 1px, never `display: none`: the browser scripts wait on it) | +- The auth token lives only in the HttpOnly cookie, never in localStorage or sessionStorage. +- A new setting needs its default and type check in `sanitizeSettings`, or it is silently dropped. +- Secret input stays in the DOM input only: never React state, a draft, a queue or a retry. +- Import only types from `shared/protocol.ts`: importing a value reads the server's environment. diff --git a/src/App.tsx b/src/App.tsx index b53fb863a..d241a355c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,27 @@ import { type CSSProperties, type DragEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { Bell, Ellipsis, FolderInput, FolderOpen, Link, Lock, Menu, MessageSquare, PanelLeft, Plus, Search, SquareTerminal, X } from "lucide-react"; +import { Bell, Ellipsis, FolderOpen, Link, Lock, Menu, MessageSquare, PanelLeft, Search, SquareTerminal, X } from "lucide-react"; import type { AgentStatus, ServerMessage, AccessRefusal, HealthAuth, HerdrPane } from "../shared/protocol.ts"; import { ApiError, authenticate, fetchHealth, fetchBridgeHealth, fetchMachines, fetchSession, pairDevice, routeMissing, sendTestPush, signOut, splitPane, zoomPane, type HealthInfo } from "./lib/api.ts"; import { deviceLabel, takePairCode } from "./lib/phone.ts"; -import { displayPaneTitle } from "./components/Sidebar.tsx"; +import { alertPaneTitle, displayPaneTitle } from "./components/Sidebar.tsx"; import { PaneCanvas } from "./components/PaneCanvas.tsx"; import { MachineSessionsProvider } from "./lib/machineSessionContext.tsx"; import { PaneTerminal } from "./components/PaneTerminal.tsx"; import { PANE_TABPANEL_ID, paneTabPanelLabel } from "./lib/paneRegion.ts"; +import { worktreeRepoName } from "./lib/composerStrip.ts"; import { AccessGate } from "./components/AccessGate.tsx"; import { AgentMark } from "./components/AgentMark.tsx"; import { NewSessionDialog, type NewTabTarget } from "./components/NewSessionDialog.tsx"; -import { MovePaneMenu } from "./components/MovePaneMenu.tsx"; import { passingFolder, recentDirectories } from "./lib/recent-directories.ts"; import { usePreferencesSync } from "./lib/preferencesSync.ts"; import { TabStrip } from "./components/TabStrip.tsx"; import { SettingsDialog } from "./components/SettingsDialog.tsx"; import { loadedOnSettingsEntry, onSettingsHistory, recordSettings, settingsEntry } from "./lib/settingsHistory.ts"; -import { fileFromLink, followMoved, isNavigation, linkSearch, linksSomewhere, NAV_KEY, readLink, resolveLink, slugOf, type AppLink, type CreateDraft, type LinkNote } from "./lib/deepLink.ts"; +import { fileFromLink, isNavigation, linkSearch, linksSomewhere, NAV_KEY, readLink, resolveLink, slugOf, type AppLink, type CreateDraft, type LinkNote } from "./lib/deepLink.ts"; import { copyText } from "./lib/clipboard.ts"; import { CommandPalette } from "./components/CommandPalette.tsx"; +import { paletteOpenQuery } from "./lib/paletteSeed.ts"; import { MachineContext } from "./lib/machineContext.tsx"; import { MachineActionBanner, MachineSidebar } from "./components/MachineSidebar.tsx"; import { SidebarResizer } from "./components/SidebarResizer.tsx"; @@ -33,7 +34,6 @@ import { paneStorageId, type Machine, type MachineEvent } from "../shared/machin import { takeAuthTokenFromUrl } from "./lib/authLink.ts"; import { applyPaneStatus } from "./lib/snapshot.ts"; import { rosterPanes } from "./lib/dagPane.ts"; -import { carryPaneRecords, paneMovedTo, paneMovePending, paneMovesVersion, recordPaneMove, subscribePaneMoves } from "./lib/paneMove.ts"; import { SnapshotRequests } from "./lib/snapshotRequests.ts"; import { alertPrefs, useSettings } from "./lib/settings.ts"; import { useShortcuts } from "./lib/shortcuts.ts"; @@ -52,15 +52,18 @@ import { type NotificationState, } from "./lib/notifications.ts"; import { ensurePushSubscription, pushSupported, removePushSubscription } from "./lib/push.ts"; -import { notificationTargetFromSearch, notificationViewForPane, onNotificationTarget, type NotificationTarget } from "./lib/notificationTarget.ts"; +import { machineGone, notificationLanding, notificationTargetFromSearch, sameLink, notificationViewForPane, onNotificationTarget, type NotificationTarget } from "./lib/notificationTarget.ts"; import { useUpdates } from "./lib/updates.ts"; import { UpdateNotice } from "./components/UpdateControls.tsx"; import { LanExposureNotice } from "./components/LanExposureNotice.tsx"; import { TelemetryNotice } from "./components/TelemetryControls.tsx"; import { FilesDialog } from "./components/FilesDialog.tsx"; +import { folderQueryFor } from "./lib/paletteFolders.ts"; import { FileViewer } from "./components/FileViewer.tsx"; +import { RepoDiffViewer } from "./components/RepoDiffViewer.tsx"; import { OpenFileContext } from "./lib/filePaths.ts"; import { useFileViewer } from "./lib/useFileViewer.ts"; +import { useRepoDiffViewer } from "./lib/useRepoDiffViewer.ts"; import { useT } from "./lib/i18n.ts"; import { markSelection, selectionStill, type SelectionMark } from "./lib/selectionMark.ts"; import { useScreenWakeLock } from "./lib/wakeLock.ts"; @@ -72,6 +75,8 @@ import { createAlertTurnPlayer } from "./lib/alertTurns.ts"; import { dockPane, draggedPane, dropSide, otherSlot, PANE_DRAG_TYPE, sameTarget, slotSide, storedSplit, storeSplit, type SplitSide, type SplitSlot, type SplitState, type SplitTarget } from "./lib/split.ts"; import { paneDrag } from "./lib/paneDrag.ts"; import { SplitDropPreview } from "./components/SplitDropPreview.tsx"; +import { CrashProbe, RegionCrash } from "./components/AppCrash.tsx"; +import { RenderBoundary } from "./components/RenderBoundary.tsx"; const APP_TITLE = "herdr web ui"; /** how soon after a key press a focus counts as the user's own (lastKeyAtRef) */ @@ -150,7 +155,7 @@ function Brand() { export function App() { const t = useT(); const apple = /Mac|iPhone|iPad/.test(navigator.platform); - const { settings, resolvedTheme, update: updateSettings } = useSettings(); + const { settings, resolvedTheme, resolvedPalette, update: updateSettings } = useSettings(); // this device's alert choices: sent with its push subscription, and applied to tab alerts here const alerts = useMemo(() => alertPrefs(settings), [settings.alertInput, settings.alertDone]); const alertsRef = useRef(alerts); @@ -240,8 +245,26 @@ export function App() { const pendingLink = useRef(initialLink && (initialLink.pane || initialLink.workspace || fileFromLink(initialLink)) ? initialLink : null); // state, not only the ref: what waits on the link landing (the address, its file) runs again when it does const [linkLanded, setLinkLanded] = useState(() => pendingLink.current === null); + // A PC is gone only by a roster the server sent after it was named (lib/notificationTarget.ts + // machineGone): the count of rosters in, and the count when the pending link and the selection + // were made (a remembered selection: 0, so the first roster checks it) + const rosterSeq = useRef(0); + const linkSince = useRef(0); + const selectionSince = useRef(0); + // the checks run again on a roster that came in while a link waits (the same list, kept as it + // was, changes nothing they watch), and on a link a tap set (its pane may be the one selected) + const [rosterTick, setRosterTick] = useState(0); + const [linkTick, setLinkTick] = useState(0); + const rosterIn = useCallback((): void => { + rosterSeq.current += 1; + if (pendingLink.current !== null) setRosterTick(rosterSeq.current); + }, []); // the file a link named, opened over its pane once that pane is shown const pendingFile = useRef(initialLink ? fileFromLink(initialLink) : null); + // the workspace a tapped alert named for the pane it selected at once: a roster in hand may + // still list a pane that has ended (a phone resumed from the background), and when the next + // one drops it, that workspace opens with a word, as a link's does. Any other pick clears it. + const selectionHint = useRef<{ machine: string; pane: string; workspace: string } | null>(null); // a short word in the header: a link that could not open what it named, or a link copied const [headerNote, setHeaderNote] = useState(null); useEffect(() => { @@ -268,6 +291,19 @@ export function App() { }, [selectedMachineId, selectedPaneId]); // on a phone the drawer follows a swipe in from the left edge, and a swipe back (lib/edgeSwipe.ts) useEffect(() => watchDrawerSwipe(() => drawerOpenRef.current, setDrawerOpen), []); + // Escape puts the open drawer away and goes no further: in the capture phase, so a terminal that kept the + // focus never receives it. A dialog or an IME composition on top keeps its own Escape + useEffect(() => { + const onEscape = (event: KeyboardEvent): void => { + if (event.key !== "Escape" || !drawerOpenRef.current || event.isComposing || event.keyCode === 229) return; + if (document.querySelector('[role="dialog"]')) return; + event.preventDefault(); + event.stopPropagation(); + setDrawerOpen(false); + }; + window.addEventListener("keydown", onEscape, true); + return () => window.removeEventListener("keydown", onEscape, true); + }, []); // a desktop opened by a link that says so starts with the sidebar collapsed, and the address says so while it is const [sidebarCollapsed, setSidebarCollapsed] = useState(() => initialLink?.sidebar === "hidden"); // the width the sidebar's edge was dragged to on this device; null is the density's own @@ -316,33 +352,23 @@ export function App() { // a tab dragged out of its strip (lib/paneDrag.ts) const tabDrag = useSyncExternalStore(paneDrag.subscribe, paneDrag.snapshot); const [paletteOpen, setPaletteOpen] = useState(false); + const [paletteQuery, setPaletteQuery] = useState(""); + const openPalette = useCallback((query?: unknown) => { setPaletteQuery(paletteOpenQuery(query)); setPaletteOpen(true); }, []); const paneControls = useRef(null); const [findRequest, setFindRequest] = useState<{ paneId: string | null; serial: number }>({ paneId: null, serial: 0 }); - // the header's More menu: its button, and whether it opened on a phone-width screen - const [more, setMore] = useState<{ anchor: HTMLElement; phone: boolean } | null>(null); + // the header's More menu: its button + const [more, setMore] = useState<{ anchor: HTMLElement } | null>(null); const closeMore = useCallback(() => setMore(null), []); // the pane canvas's actions, for the phone's tab menu (TabStrip) const paneActionsRef = useRef<((target: HerdrPane) => RowMenuItem[]) | null>(null); - const moreOpen = more !== null; - // a sheet stays up through a resize: its palette item follows the header's palette button, - // which the same breakpoint hides - useEffect(() => { - if (!moreOpen) return; - const media = window.matchMedia("(max-width: 480px)"); - const onChange = (): void => setMore((open) => open && { ...open, phone: media.matches }); - media.addEventListener("change", onChange); - return () => media.removeEventListener("change", onChange); - }, [moreOpen]); // the Files dialog, and the file open in the viewer (a path as the chat or the dialog gave it) const [filesOpen, setFilesOpen] = useState(false); - const [movingPane, setMovingPane] = useState<{ machineId: string; paneId: string; anchor: HTMLElement } | null>(null); - const movingOwner = movingPane ? machines.find((machine) => machine.id === movingPane.machineId) : null; - const movingLivePane = movingOwner?.state === "connected" ? movingOwner.snapshot?.panes.find((pane) => pane.pane_id === movingPane?.paneId) : null; - useEffect(() => { if (movingPane && !movingLivePane) setMovingPane(null); }, [movingPane, movingLivePane]); const { viewing, openFile, closeFile } = useFileViewer(); const viewFile = useCallback((path: string) => { openFile({ path, paneId: selectedPaneId, machineId: selectedMachineId }); }, [openFile, selectedPaneId, selectedMachineId]); + // a repository diff from the Files dialog's Changes tab, an entry of its own like a file + const { viewing: diffViewing, openDiff, closeDiff } = useRepoDiffViewer(); // a link to Settings opens it on its page, for that first opening alone const [settingsOpen, setSettingsOpen] = useState(() => initialLink?.settings != null); const [settingsLinkPage, setSettingsLinkPage] = useState(() => initialLink?.settings ?? null); @@ -376,6 +402,8 @@ export function App() { // the dialog makes a tab in this workspace instead of a workspace, while set const [newTab, setNewTab] = useState(null); const [connected, setConnected] = useState(false); + // the active slot's pane could not be drawn: no terminal is attached there to say so itself + const paneLost = useCallback(() => setConnected(false), []); const [outputStopped, setOutputStopped] = useState(false); const [notifications, setNotifications] = useState(() => notificationState()); // this device has a server-side push subscription: alerts come from the server, not the tab @@ -411,6 +439,7 @@ export function App() { try { await snapshotRequests.current.read(fetchMachines, (next) => { setMachines((previous) => sameData(previous, next) ? previous : next); + rosterIn(); setMachineRosterLoaded(true); setError(null); setLocked(false); }); @@ -419,7 +448,7 @@ export function App() { if (err instanceof ApiError && err.status === 401) { setLocked(true); return; } setError(err instanceof Error ? err.message : String(err)); } - }, []); + }, [rosterIn]); useEffect(() => { const tick = (): void => { @@ -493,7 +522,7 @@ export function App() { machineId: machine.id, paneId: pane.pane_id, agent: pane.agent ?? null, - title: displayPaneTitle(pane), + title: alertPaneTitle(pane), machine: machinesRef.current.length > 1 ? machine.name : null, kind, }); @@ -545,6 +574,7 @@ export function App() { if (payload.type === "machines") { seedStatuses(statusRef.current, alerting(payload.machines), { started: turnStartRef.current, lasted: lastTurnRef.current }); setMachines((previous) => sameData(previous, payload.machines) ? previous : payload.machines); + rosterIn(); setMachineRosterLoaded(true); return; } @@ -564,7 +594,7 @@ export function App() { dropIn(machine, pane, kind); chime(machine, pane, kind); } - if (pane && shouldNotifyStatus(previous, status) && alertsOnRef.current && !pushOnRef.current && alertsAllow(alertsRef.current, status)) showPaneStatusNotification(message.pane_id, `${machine.name} · ${displayPaneTitle(pane)}`, status, () => selectTargetRef.current(machine.id, message.pane_id, "chat"), machine.id); + if (pane && shouldNotifyStatus(previous, status) && alertsOnRef.current && !pushOnRef.current && alertsAllow(alertsRef.current, status)) showPaneStatusNotification(message.pane_id, `${machine.name} · ${alertPaneTitle(pane)}`, status, () => takeNotificationTargetRef.current({ machine_id: machine.id, pane_id: message.pane_id, view: "chat", workspace_id: pane.workspace_id }), machine.id, pane.workspace_id); setMachines((list) => { let changed = false; const next = list.map((m) => { @@ -587,7 +617,7 @@ export function App() { } if (message.type === "pane-exited" && alertsOnRef.current && !pushOnRef.current && alertsRef.current.done !== "off") { const pane = machine.snapshot?.panes.find((p) => p.pane_id === message.pane_id); - if (pane) showPaneEndedNotification(message.pane_id, `${machine.name} · ${displayPaneTitle(pane)}`, () => selectTargetRef.current(machine.id, message.pane_id, "chat"), machine.id); + if (pane) showPaneEndedNotification(message.pane_id, `${machine.name} · ${alertPaneTitle(pane)}`, () => takeNotificationTargetRef.current({ machine_id: machine.id, pane_id: message.pane_id, view: "chat", workspace_id: pane.workspace_id }), machine.id, pane.workspace_id); } if (message.type === "session-changed" || message.type === "pane-exited") scheduleRefetch(); }; @@ -698,11 +728,11 @@ export function App() { storeSelection(machineId, paneId); // a pane picked (a tapped notification, the sidebar, Back) before a link landed supersedes the link pendingLink.current = null; pendingFile.current = null; setLinkLanded(true); + selectionHint.current = null; + // a PC picked now (Open PC, before load() brings it) is checked against a roster sent after this + selectionSince.current = rosterSeq.current; }, [inOtherHalf, setSplit]); const selectTargetRef = useRef(selectTarget); selectTargetRef.current = selectTarget; - // the moves this client asked for and has no answer to yet (MovePaneMenu): the effect below - // looks again when one ends, since a failed move leaves the selection to it after all - const movesVersion = useSyncExternalStore(subscribePaneMoves, paneMovesVersion); useEffect(() => { // An offline PC's cached roster cannot invalidate a selection. Once connected, // a closed pane (including one remembered across reloads) must release its selection. @@ -756,20 +786,29 @@ export function App() { let cancelled = false; void fetchSession(selectedMachineId).then((current) => { if (cancelled || current.panes.some((pane) => pane.pane_id === selectedPaneId)) return; - // the pane is gone because this client is moving it and the roster outran the answer: - // the answer names the id to follow (actions.paneMoved), and herdr's focus is not it - if (paneMovePending(selectedMachineId, selectedPaneId)) return; + // a pane a tapped alert selected: the workspace the alert named opens, and says so + const hint = selectionHint.current; + selectionHint.current = null; + if (hint && hint.machine === selectedMachineId && hint.pane === selectedPaneId) { + const landed = resolveLink(current, { workspace: hint.workspace, workspaceSlug: null, pane: hint.pane }); + if (landed.note) setHeaderNote(landed.note); + setNotificationViewTarget(null); + setSelectedPaneId(landed.pane ?? fallback(current)); + setAutoSelected(true); + return; + } setSelectedPaneId(fallback(current)); setAutoSelected(true); }).catch(() => { /* a failed read is not evidence that the pane disappeared */ }); return () => { cancelled = true; }; - }, [snapshot, selectedPaneId, selectedMachineId, selectedMachine?.state, movesVersion]); + }, [snapshot, selectedPaneId, selectedMachineId, selectedMachine?.state, linkTick]); useEffect(() => { storeSelection(selectedMachineId, selectedPaneId); }, [selectedMachineId, selectedPaneId]); const selectPane = useCallback((paneId: string, focusInput = true) => { pendingLink.current = null; pendingFile.current = null; setLinkLanded(true); + selectionHint.current = null; const open = splitRef.current; const current = selectionRef.current; if (open && current.paneId !== null && inOtherHalf(current.machineId, paneId)) { @@ -789,8 +828,28 @@ export function App() { else console.warn(what, err); }, [t]); - // a tapped notification focuses this window and names the pane (public/sw.js) - useEffect(() => onNotificationTarget((target) => selectTargetRef.current(target.machine_id, target.pane_id, target.view)), []); + // a tapped notification focuses this window and names the pane (public/sw.js). A pane not known + // yet waits as a link does (this also keeps a cold start's link, which the worker names again + // once the page is up), and so does one on a PC this roster lacks: a roster asked for now says + // whether it was removed (the [machines] effect below) or set up since this one was read + const takeNotificationTarget = useCallback((target: NotificationTarget): void => { + const landing = notificationLanding(machinesRef.current, target); + if (landing.kind === "select") { + selectTargetRef.current(target.machine_id, target.pane_id, target.view); + if (target.workspace_id) selectionHint.current = { machine: target.machine_id, pane: target.pane_id, workspace: target.workspace_id }; + return; + } + // the worker names one tap twice, before and after focus: the second leaves the first's lookup be + if (sameLink(pendingLink.current, landing.link)) return; + selectTargetRef.current(target.machine_id, target.pane_id, target.view); + pendingLink.current = landing.link; pendingFile.current = null; setLinkLanded(false); + linkSince.current = rosterSeq.current; + setLinkTick((tick) => tick + 1); + if (landing.stale) void load(); + }, [load]); + // the tab's own alerts shown without a worker (lib/notifications.ts) land the same way + const takeNotificationTargetRef = useRef(takeNotificationTarget); takeNotificationTargetRef.current = takeNotificationTarget; + useEffect(() => onNotificationTarget(takeNotificationTarget), [takeNotificationTarget]); const selectedPane = snapshot?.panes.find((pane) => pane.pane_id === selectedPaneId) ?? null; @@ -955,6 +1014,9 @@ export function App() { const inside = !!box && !!point && point.x >= box.left && point.x < box.right && point.y >= box.top && point.y < box.bottom; showDropTarget(box && point && inside ? dropSide(point.x, box.left, box.width, dropAtRef.current) : null); }, [tabDrag, splitDragOn, showDropTarget]); + // dragenter too: a drop hit-tests once more, and when the node under the pointer was replaced since + // the last dragover (xterm redraws its rows, a transcript renders again) the new node gets a + // dragenter instead. Chromium refuses a drop whose last drag event nobody accepted. const paneDragOver = (event: DragEvent): void => { if (!splitDragOn || !event.dataTransfer.types.includes(PANE_DRAG_TYPE)) return; event.preventDefault(); @@ -1031,9 +1093,7 @@ export function App() { // Back and Forward land where the address already says: nothing to write if (search === window.location.search) return; const url = `${window.location.pathname}${search}${window.location.hash}`; - // a move gives the pane a new id (lib/paneMove.ts): the same pane, so the same entry - const sameMoved = previous !== null && previous.machine === link.machine && paneMovedTo(previous.machine, previous.pane) === link.pane; - if (previous !== null && !popped && !sameMoved && !autoSelected && !settingsOpen && !newSessionOpen && isNavigation(previous, link)) window.history.pushState({ [NAV_KEY]: true }, "", url); + if (previous !== null && !popped && !autoSelected && !settingsOpen && !newSessionOpen && isNavigation(previous, link)) window.history.pushState({ [NAV_KEY]: true }, "", url); else window.history.replaceState(window.history.state, "", url); }, [locked, machines.length, snapshot, linkLanded, selectedMachineId, selectedPaneId, linkWorkspaceId, linkSlug, linkFile, view, settingsOpen, settingsPage, settingsLinkPage, settingsLinkSection, newSessionOpen, newTab, newFields, wideScreen, sidebarCollapsed, autoSelected]); @@ -1061,6 +1121,7 @@ export function App() { if (!link.pane && !link.workspace) return; selectTargetRef.current(link.machine, link.pane, link.view ?? undefined); pendingLink.current = link; pendingFile.current = null; setLinkLanded(false); + linkSince.current = rosterSeq.current; poppedTo.current = true; return; } @@ -1073,13 +1134,12 @@ export function App() { // an entry naming a PC since removed leaves the app where it is and says so, as a link to it would if (missing) setHeaderNote("machine-missing"); else { - const target = followMoved(link, paneMovedTo(link.machine, link.pane), machine?.snapshot); - const pane = machine?.snapshot ? resolveLink(machine.snapshot, target).pane : target.pane; + const pane = machine?.snapshot ? resolveLink(machine.snapshot, link).pane : link.pane; if (pane === null) return; const current = selectionRef.current; // the entry's lens is drawn over the pane's own, as a tapped notification's is. Where it // lands is written over the entry, so Forward keeps the entries after it - if (current.machineId !== link.machine || current.paneId !== pane) { poppedTo.current = true; selectTargetRef.current(link.machine, pane, target.pane === pane ? link.view ?? undefined : undefined); return; } + if (current.machineId !== link.machine || current.paneId !== pane) { poppedTo.current = true; selectTargetRef.current(link.machine, pane, link.pane === pane ? link.view ?? undefined : undefined); return; } } // the same pane, or no PC to go to: the entry landed on may still say what is no longer shown // (the address a link to Settings opened the page with, under the dialog's own entries), so it says it now @@ -1140,7 +1200,7 @@ export function App() { if (next) selectPane(next.pane_id); }, setView, - toggleView: () => setView(view === "chat" ? "terminal" : "chat"), + toggleView: () => { setView(view === "chat" ? "terminal" : "chat"); setDrawerOpen(false); }, scrollPage: (direction) => paneControls.current?.scrollPage(direction), openNewSession: () => { setDrawerOpen(false); @@ -1167,20 +1227,12 @@ export function App() { setNewTab({ workspaceId: workspace.workspace_id, workspaceLabel: workspace.label, cwd: workspace.worktree?.checkout_path ?? inFront?.cwd ?? null, number: workspace.tab_count + 1 }); setNewSessionOpen(true); }, - openPalette: () => setPaletteOpen(true), - paneMoved: (machineId, previousPaneId, paneId) => { - if (previousPaneId === paneId) return; - carryPaneRecords(machineId, previousPaneId, paneId); - recordPaneMove(machineId, previousPaneId, paneId); - lenses.move(paneStorageId(machineId, previousPaneId), paneStorageId(machineId, paneId)); - // the new id is selected before a snapshot without the old one can fall the selection - // back to herdr's focus; a pane moved from the sidebar while another is open stays unselected - const current = selectionRef.current; - if (current.machineId === machineId && current.paneId === previousPaneId) selectPane(paneId); - }, + openPalette, + agentStarted: (machineId, paneId) => lenses.startedAgent(paneStorageId(machineId, paneId), window.matchMedia?.("(pointer: coarse)").matches === true), openFind: () => { if (selectedPaneId === null) return; setView("terminal"); + setDrawerOpen(false); setFindRequest((request) => ({ paneId: selectedPaneId, serial: request.serial + 1 })); }, openSettings: () => { @@ -1232,19 +1284,22 @@ export function App() { // The header's More menu: what used to be three buttons of its own. Each item is there under // the condition its button had. At phone width the palette's button gives its room to the - // pane's title, and the palette is the menu's first item. - const paletteItem: RowMenuItem = { id: "palette", label: t("Command palette"), icon: Search, run: () => setPaletteOpen(true) }; + // pane's title. // a link to a PC this app does not have lands on this PC instead, and says so: the roster lists - // every configured PC, connected or not, so one missing from it is not set up here + // every configured PC, connected or not, so one missing from it is not set up here. So does a + // selection of a PC removed since (a reload, a new window), here or on another device. Only a + // roster the server sent after the PC was named decides (lib/notificationTarget.ts machineGone) useEffect(() => { const linked = pendingLink.current; - if (!linked || machines.length === 0 || machines.some((machine) => machine.id === linked.machine)) return; + const machineId = linked ? linked.machine : selectedMachineRef.current; + const since = linked ? linkSince.current : selectionSince.current; + if (!machineGone(machines, rosterSeq.current, since, machineId)) return; pendingLink.current = null; pendingFile.current = null; setLinkLanded(true); setHeaderNote("machine-missing"); // no pane: the link's pane id means nothing on another PC, so the pane herdr has in front opens - selectTarget("local", null); - }, [machines, selectTarget]); + selectTargetRef.current("local", null); + }, [machines, rosterTick, linkTick]); // a link to the New workspace dialog opens it once the PCs are in; one for a tab, once the // linked workspace's pane is selected (its folder is the workspace's, as the tab strip's + does) @@ -1270,13 +1325,6 @@ export function App() { const moreItems: RowMenuItem[] = [ ...(selectedPane && !selectedPane.restore_error ? [{ id: "find", label: t("Find in terminal"), icon: Search, run: actions.openFind }] : []), - ...(selectedPane && selectedWorkspace - ? [{ id: "new-tab", label: t("New tab"), title: t("New tab in {workspace}", { workspace: selectedWorkspace.label }), icon: Plus, run: () => actions.openNewTab() }] - : []), - ...(selectedPane ? [{ id: "move-pane", label: t("Move pane to…"), icon: FolderInput, run: () => { - const anchor = document.querySelector(".header-more-button"); - if (anchor) setMovingPane({ machineId: selectedMachineId, paneId: selectedPane.pane_id, anchor }); - } }] : []), ...(selectedPane ? [{ id: "files", label: t("Browse files"), icon: FolderOpen, run: () => setFilesOpen(true) }] : []), // the address says where the app is (lib/deepLink.ts); a phone has no address bar to copy it from { id: "copy-link", label: t("Copy link"), icon: Link, run: () => void copyText(window.location.href).then((ok) => { if (ok) setHeaderNote("copied"); }) }, @@ -1342,7 +1390,7 @@ export function App() { > - @@ -1374,11 +1422,11 @@ export function App() { )} {selectedPane && (
- - )} - {/* with nothing of its own to offer it is still the phone's way to the palette */} - +
) : undefined} - items={more.phone ? [paletteItem, ...moreItems] : moreItems} + items={moreItems} onClose={closeMore} /> )} @@ -1440,7 +1487,11 @@ export function App() {
@@ -1458,7 +1509,7 @@ export function App() { {health?.lan_exposure && { setSettingsLinkPage("devices"); setSettingsOpen(true); }} />} {/* the pane's tab, or two tabs side by side (lib/split.ts): a tab dragged down out of its strip, or a sidebar row dragged over this area, opens its pane in the half it is dropped on */} -
+
{(["a", "b"] as const).map((slot) => { // one pane: the solo slot, or a split's active half where the window is too narrow for both if (!splitOn && slot !== (split ? split.active : soloSlot)) return null; @@ -1466,6 +1517,7 @@ export function App() { const machineId = active ? selectedMachineId : split.other.machineId; const paneId = active ? selectedPaneId : split.other.paneId; const machine = active ? selectedMachine : otherMachine; + const herdr = active ? targetHerdr : otherHerdr; const pane = active ? selectedPane : otherPane; const workspace = active ? selectedWorkspace : otherWorkspace; const slotSnapshot = active ? snapshot : otherMachine?.snapshot ?? null; @@ -1510,6 +1562,10 @@ export function App() {
)} + {/* a pane this half cannot draw leaves the sidebar and the other half working; picking + another pane tries again */} + }> + {slotSnapshot && pane && workspace && ( actions.openNewTab(active ? undefined : { machineId, workspaceId: workspace.workspace_id })} @@ -1526,13 +1582,13 @@ export function App() {
lensOf(item) === "terminal"} renderPane={(item, itemActive, herdrSplit) => openFile({ path, paneId: item.pane_id, machineId }) : null}> workspace.workspace_id === item.workspace_id))} machineName={machine?.name ?? machineId} view={item ? lensOf(item) : slotView} // the other half never takes the keyboard: a press in it makes it active first @@ -1551,18 +1609,17 @@ export function App() { autoSelected={active ? autoSelected : true} terminalFontSize={settings.terminalFontSize} split={herdrSplit || splitOn} - terminalCursorStyle={settings.terminalCursorStyle} - terminalCursorBlink={settings.terminalCursorBlink} terminalWheelSpeed={settings.terminalWheelSpeed} terminalFontFamily={settings.terminalFontFamily} theme={resolvedTheme} - palette={settings.palette} + palette={resolvedPalette} onConnectionChange={active && itemActive ? (next) => { setConnected(next); if (next) setOutputStopped(false); } : undefined} onServerMessage={active && itemActive ? handleServerMessage : undefined} /> } />
+
); @@ -1585,7 +1642,7 @@ export function App() { onClose={closeNewSession} onCreated={(paneId, agentStarted) => { closeNewSession(); - if (agentStarted) lenses.startedAgent(paneStorageId(newSessionMachineId, paneId), window.matchMedia?.("(pointer: coarse)").matches === true); + if (agentStarted) actions.agentStarted(newSessionMachineId, paneId); selectTarget(newSessionMachineId, paneId); void load(); }} @@ -1598,17 +1655,20 @@ export function App() { setFilesOpen(false); selectTargetRef.current(machineId, paneId); }} /> - {movingPane && movingLivePane && movingOwner?.snapshot && actions.paneMoved(movingPane.machineId, moved.previous_pane_id, moved.pane.pane_id)} - onError={(reason) => setLayoutNotice(t("Move failed: {reason}", { reason }))} onClose={() => setMovingPane(null)} />} - + {filesOpen && selectedPane && ( - setFilesOpen(false)} /> + openDiff({ machineId: selectedMachineId, paneId: selectedPane.pane_id, directory: selectedPane.foreground_cwd ?? selectedPane.cwd ?? null, ...comparison, path: file.path, oldPath: file.old_path })} + onBrowseFolders={(path) => actions.openPalette(folderQueryFor(path, null))} + onClose={() => setFilesOpen(false)} /> )} + {diffViewing !== null && + openFile({ path, paneId: diffViewing.paneId, machineId: diffViewing.machineId })} keyboardActive={!settingsOpen && viewing === null} /> + } {viewing !== null && openFile({ ...viewing, path })} keyboardActive={!settingsOpen} /> } - setPaletteOpen(false)} snapshot={snapshot} online={selectedMachine?.state === "connected"} selectedPaneId={selectedPaneId} view={view} actions={actions} /> + setPaletteOpen(false)} snapshot={snapshot} online={selectedMachine?.state === "connected"} selectedPaneId={selectedPaneId} view={view} actions={actions} />
); } diff --git a/src/components/AgentPicker.tsx b/src/components/AgentPicker.tsx index 7cb28880c..6f9ce386d 100644 --- a/src/components/AgentPicker.tsx +++ b/src/components/AgentPicker.tsx @@ -59,7 +59,8 @@ export const AgentPicker = forwardRef(funct if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; const onMove = (event: Event): void => { - if (!(event.target instanceof Node && rootRef.current?.contains(event.target))) setOpen(false); + // Only an ancestor's scroll can move the anchor; text inputs scroll too. + if (event.type === "resize" || (event.target instanceof Node && rootRef.current !== null && event.target.contains(rootRef.current))) setOpen(false); }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("scroll", onMove, true); @@ -76,7 +77,15 @@ export const AgentPicker = forwardRef(funct }, [disabled]); useEffect(() => { - if (open) document.getElementById(`${listId}-${active}`)?.scrollIntoView({ block: "nearest" }); + if (!open) return; + const option = document.getElementById(`${listId}-${active}`); + const menu = option?.parentElement; + if (!option || !menu) return; + // Scroll only the list; moving its modal would close the anchored picker. + const optionBounds = option.getBoundingClientRect(); + const menuBounds = menu.getBoundingClientRect(); + if (optionBounds.top < menuBounds.top) menu.scrollTop -= menuBounds.top - optionBounds.top; + else if (optionBounds.bottom > menuBounds.bottom) menu.scrollTop += optionBounds.bottom - menuBounds.bottom; }, [open, active, listId]); const show = (): void => { diff --git a/src/components/AgentSidebar.css b/src/components/AgentSidebar.css index 2c552fea4..4792e4b43 100644 --- a/src/components/AgentSidebar.css +++ b/src/components/AgentSidebar.css @@ -79,60 +79,30 @@ list-style: none; } -/* In herdr's order a workspace's agents on one PC sit in a card under its name. An edge, not a - fill, so a row's hover and selection read inside it as they do anywhere else. */ -.agent-group { - padding: var(--space-1); - border: var(--hairline) solid var(--border); - border-radius: var(--radius-lg); -} - -.agent-group + .agent-group { margin-top: var(--space-2); } - -/* the card's name: a step under the Agents head, its folder in the rows' leading cell */ -.agent-group-head { +/* Pinned agents stand first, under a quiet head in the rows' text column; the rest follow after a gap. */ +.agent-pinned-head { display: flex; align-items: center; - min-width: 0; min-height: var(--space-6); + margin: 0; + padding-left: var(--side-box); color: var(--text-dim); font-size: var(--fs-xs); line-height: var(--lh-tight); } -.agent-group-mark { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 var(--side-box); - width: var(--side-box); -} +.agent-list-pinned + .agent-list { margin-top: var(--space-2); } -.agent-group-mark svg { - width: var(--space-4); - height: var(--space-4); -} - -.agent-group-name { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.agent-group-rows { - margin: 0; - padding: 0; - list-style: none; -} - -/* its rows dim themselves; the head dims with them */ -.agent-group.is-offline > .agent-group-head { opacity: 0.5; } - -/* The agent row selects its pane; the star beside it is its only other control. */ +/* The agent row selects its pane; its ⋯ (or a right-click) opens its pin menu. */ +/* The trailing padding keeps the state cell, which the row draws at its right edge after the ⋯, as + a workspace row does; the fill is the whole row's. */ .agent-item { + position: relative; display: flex; align-items: center; + padding-right: var(--side-box); + border-radius: var(--radius-md); + transition: background-color var(--dur-fast) var(--ease-out); } .agent-row { @@ -149,43 +119,42 @@ font: inherit; text-align: left; cursor: pointer; - transition: background-color var(--dur-fast) var(--ease-out); } .agent-row:focus-visible { outline-offset: calc(var(--ring-offset) * -1); } .agent-row:disabled { cursor: default; } @media (hover: hover) { - .agent-row:enabled:hover { background: var(--side-hover); } + .agent-item:has(> .agent-row:enabled):hover { background: var(--side-hover); } } -.agent-item.is-selected > .agent-row { background: var(--side-selected); } +.agent-item.is-selected { background: var(--side-selected); } -.agent-pin-toggle { - display: inline-flex; +/* As a workspace row's: the ⋯ takes no room until the row is hovered, focused, selected or its + menu is open, and stays in the focus order, since the menu hands the focus back to it. */ +.agent-actions { + display: flex; align-items: center; - justify-content: center; - flex: 0 0 var(--side-box); - width: var(--side-box); - height: var(--side-row-2); - padding: 0; - border: 0; - border-radius: var(--radius-md); - background: transparent; - color: var(--text-dim); - cursor: pointer; + flex: 0 0 auto; + width: 0; + overflow: hidden; + opacity: 0; } -.agent-pin-toggle svg { - width: var(--space-4); - height: var(--space-4); +.agent-item:focus-within > .agent-actions, +.agent-item.is-selected > .agent-actions, +.agent-actions:has([aria-expanded="true"]) { + width: auto; + overflow: visible; + opacity: 1; } -.agent-pin-toggle.is-pinned { color: var(--accent); } -.agent-pin-toggle:focus-visible { outline-offset: calc(var(--ring-offset) * -1); } - @media (hover: hover) { - .agent-pin-toggle:hover { color: var(--text-strong); } + .agent-item:hover > .agent-actions { + width: auto; + overflow: visible; + opacity: 1; + } } .agent-copy { @@ -229,6 +198,16 @@ align-items: center; } +.agent-row > .agent-row-state { + position: absolute; + inset-block: 0; + right: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--side-box); +} + .agent-item.is-offline { opacity: 0.5; } .agent-empty { @@ -241,16 +220,20 @@ @media (pointer: coarse) { .agent-section-caret { opacity: 1; } + /* Row menus stay visible on touch. */ + .agent-actions { + width: auto; + overflow: visible; + opacity: 1; + } } @media (prefers-reduced-motion: reduce) { - .agent-row { transition: none; } + .agent-item { transition: none; } } /* a finger presses a row as an iPhone list row is pressed: it fills, it does not dim (styles.css) */ @media (hover: none) and (pointer: coarse) { - .agent-row:enabled:active { - opacity: 1; - background: var(--side-hover); - } + .agent-row:enabled:active { opacity: 1; } + .agent-item:has(> .agent-row:enabled:active) { background: var(--side-hover); } } diff --git a/src/components/AgentSidebar.tsx b/src/components/AgentSidebar.tsx index a991f7594..913c6653f 100644 --- a/src/components/AgentSidebar.tsx +++ b/src/components/AgentSidebar.tsx @@ -1,17 +1,18 @@ -import { useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { ChevronDown, ChevronRight, Folder, Star, Terminal } from "lucide-react"; +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { ChevronDown, ChevronRight, Ellipsis, Pin, PinOff, Terminal } from "lucide-react"; import type { Machine } from "../../shared/machines.ts"; import { paneStorageId } from "../../shared/machines.ts"; import type { AgentStatus, PlanSummary } from "../../shared/protocol.ts"; import { useT } from "../lib/i18n.ts"; -import { agentContext, agentTabName, groupRows, paneMark, sidebarAgents, type SidebarAgent } from "../lib/sidebarAgents.ts"; +import { agentContext, agentTabName, paneMark, sidebarAgents, type SidebarAgent } from "../lib/sidebarAgents.ts"; import { useSettings } from "../lib/settings.ts"; import { useSidebarActivity } from "../lib/sidebarActivity.tsx"; import { activityOrder } from "../lib/sidebarOrder.ts"; import { currentSidebarPins, pruneSidebarPins, setSidebarPins, sidebarPinKey, splitPinnedRows, subscribeSidebarPins, toggleSidebarPin } from "../lib/sidebarPins.ts"; import { AgentMark, hasAgentMark } from "./AgentMark.tsx"; -import { BackgroundBadge, displayPaneTitle, PlanBadge, StatusBadge } from "./Sidebar.tsx"; +import { RowMenu } from "./RowMenu.tsx"; +import { BackgroundBadge, displayPaneTitle, onRowContextMenu, PlanBadge, StatusBadge } from "./Sidebar.tsx"; import "./AgentSidebar.css"; interface AgentRowBodyProps { @@ -44,7 +45,9 @@ function AgentRowBody({ mark, markKind, title, name, context, backgroundTasks, p {context} } - + + {/* drawn at the row's right edge, after its ⋯, as a workspace row's state is (AgentSidebar.css) */} + ; } @@ -59,18 +62,15 @@ export interface AgentSidebarProps { /** the width under which the sidebar is a drawer (src/styles.css) */ const DRAWER_QUERY = "(max-width: 768px)"; -/** Settings → Pin agents off: saved pins stay stored, and none of them leads the list */ -const NO_PINS: readonly string[] = []; - interface AgentRow { machine: Machine; entry: SidebarAgent } +/** The row whose menu is open, by its pin key: at its ⋯, or at the pointer for a right-click */ +interface PinMenu { key: string; anchor: HTMLElement; point?: { x: number; y: number }; previousFocus?: HTMLElement | null; title: string; place: string } /** * All PCs' live agents form a second list; workspace and PC folds do not hide these rows. * In the phone's drawer the list starts folded, leaving the room to the workspaces. - * The pinned agents stand first. With Settings → Workspace cards on, herdr's order puts each - * workspace's agents on a PC in a card under its name, a pinned agent first in its own card and - * the cards that hold pins first; off (the default), or in Activity order, which mixes - * workspaces, the rows stand alone. + * The pinned agents stand first, under a Pinned head. A row's ⋯, or a right-click on it, opens its + * menu, which pins or unpins it. */ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stateWord, onSelect }: AgentSidebarProps) { const t = useT(); @@ -86,32 +86,34 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat const agents = sidebarAgents(machine.snapshot); return (byActivity ? activityOrder(agents, (entry) => entry.pane, activity.seqs(machine.id)) : agents).map((entry): AgentRow => ({ machine, entry })); }), [machines, byActivity, activity]); - // pins lead: alone, the pinned rows stand first; in cards, each stays in its own card, first there, - // and a card that holds one comes before the cards that hold none - const split = useMemo(() => splitPinnedRows(rows, settings.agentPins ? pinned : NO_PINS, ({ machine, entry }) => sidebarPinKey(machine.id, entry.pane.pane_id)), [rows, pinned, settings.agentPins]); - // a pin moves the row, and maybe its card, so React may draw a new star: a star that had the - // keyboard focus hands it to its successor - const sectionRef = useRef(null); - const refocusPin = useRef(null); - useLayoutEffect(() => { - const key = refocusPin.current; - if (key === null) return; - refocusPin.current = null; - sectionRef.current?.querySelector(`.agent-pin-toggle[data-pin-key="${CSS.escape(key)}"]`)?.focus(); - }, [pinned]); - const setPin = (key: string, star: HTMLButtonElement): void => { - if (document.activeElement === star) refocusPin.current = key; + // the pinned rows stand first, in the order they were pinned + const split = useMemo(() => splitPinnedRows(rows, pinned, ({ machine, entry }) => sidebarPinKey(machine.id, entry.pane.pane_id)), [rows, pinned]); + const togglePin = (key: string): void => { // pins of panes gone from this server leave with a change of the user's, never by themselves: the // list is shared, and a pane this page has not seen yet (a PC still loading) is not gone const live = new Set(rows.map(({ machine, entry }) => sidebarPinKey(machine.id, entry.pane.pane_id))); setSidebarPins(toggleSidebarPin(pruneSidebarPins(pinned, live, machines), key)); }; - const carded = settings.agentCards && !byActivity; - const groups = useMemo(() => !carded ? null : groupRows([...split.pinned, ...split.rest], ({ machine, entry }) => JSON.stringify([machine.id, entry.workspace.workspace_id])), [split, carded]); - // herdr names a PC only beside another: the rows' line, and a card's head, do the same + // a pin moves the row to the other list, so React draws it anew: the control of the row that had + // the focus (its row or its ⋯) gets it back + const sectionRef = useRef(null); + const refocusPin = useRef<{ key: string; control: string } | null>(null); + useLayoutEffect(() => { + const target = refocusPin.current; + if (target === null) return; + refocusPin.current = null; + sectionRef.current?.querySelector(`.agent-item[data-pin-key="${CSS.escape(target.key)}"] .${target.control}`)?.focus({ preventScroll: true }); + }, [pinned]); + const [menu, setMenu] = useState(null); + const closeMenu = useCallback(() => setMenu(null), []); + // a row that left the list (its pane closed) takes its menu with it + useEffect(() => { + if (menu && !menu.anchor.isConnected) setMenu(null); + }, [menu, split]); + // herdr names a PC only beside another: the rows' line does the same const machineName = (machine: Machine): string | null => machines.length > 1 ? machine.name : null; - const agentItem = ({ machine, entry }: AgentRow, grouped: boolean) => { + const agentItem = ({ machine, entry }: AgentRow) => { const { pane, workspace, tab, agent, agentLabel } = entry; const selected = machine.id === selectedMachineId && pane.pane_id === selectedPaneId; const online = machine.state === "connected"; @@ -123,26 +125,39 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat const mark = paneMark(entry); const markAgent = mark && hasAgentMark(mark) ? mark : null; const parts = { agentLabel: kindLabel, title, markAgent, machineName: machineName(machine), workspaceLabel: workspace.label, tabName: agentTabName(tab, tabs, t) }; - // a row in a card leaves its place to the card's head; the tooltip still says all of it - const place = agentContext(parts).join(" · "); - const contextParts = agentContext({ ...parts, grouped, named: name !== null }); + const contextParts = agentContext(parts); const context = contextParts.join(" · "); const kind = kindLabel?.trim() || null; const markKind = kind && kind !== title.trim() && !contextParts.includes(kind) ? kind : null; - const tooltip = [...new Set([pane.pane_id, title, place, markKind, name, agent?.display_agent, pane.cwd, online ? null : stateWord(machine)].filter(Boolean))].join("\n"); + const tooltip = [...new Set([pane.pane_id, title, context, markKind, name, agent?.display_agent, pane.cwd, online ? null : stateWord(machine)].filter(Boolean))].join("\n"); const pinKey = sidebarPinKey(machine.id, pane.pane_id); - const isPinned = pinned.includes(pinKey); - return
  • + const menuOpen = menu?.key === pinKey; + // one menu for the ⋯ and a right-click, which opens it at the pointer and leaves the focus where it was + const toggleMenu = (anchor: HTMLElement, point?: { x: number; y: number }, previousFocus?: HTMLElement | null): void => + setMenu(menuOpen ? null : { key: pinKey, anchor, point, previousFocus, title, place: context }); + return
  • { if (event.button === 2 || (event.button === 0 && event.ctrlKey)) event.preventDefault(); }} + onContextMenu={(event) => onRowContextMenu(event, toggleMenu)}> - {settings.agentPins && } +
    + +
  • ; }; + const menuPinned = menu !== null && pinned.includes(menu.key); + const runPin = (state: PinMenu): void => { + // where the menu hands the focus back (lib RowMenu): the ⋯ itself unless the right-click said otherwise + const back = state.previousFocus === undefined ? state.anchor : state.previousFocus; + const control = back?.closest(`.agent-item[data-pin-key="${CSS.escape(state.key)}"]`) ? back.classList.contains("row-menu-toggle") ? "row-menu-toggle" : "agent-select" : null; + if (control) refocusPin.current = { key: state.key, control }; + togglePin(state.key); + }; return
    + {menu && runPin(menu) } + : { id: "pin", label: t("Pin agent"), icon: Pin, run: () => runPin(menu) }, + ]} />}
    ; } diff --git a/src/components/AnimatedHeight.css b/src/components/AnimatedHeight.css new file mode 100644 index 000000000..593357bae --- /dev/null +++ b/src/components/AnimatedHeight.css @@ -0,0 +1,28 @@ +/* a box that follows its content's measured height (AnimatedHeight.tsx): the height is set in + px and transitioned; at rest the content shows whole, however it overhangs */ +.animated-height { + transition: height var(--dur-base) var(--ease-out); +} + +/* Content inside an open work block resizes once, not once per transition frame. */ +.animated-height.is-static { + transition: none; +} + +/* a block formatting context: a child's margin does not escape what is measured */ +.animated-height-content { + display: flow-root; +} + +/* on the move, what lies past the box's edge is clipped: an opening is revealed and a fold + covered from the bottom. The clip is cut exactly top and bottom and let out at the sides, so a + row's hover plate and focus ring, which overhang the block (ChatView.css), stay whole */ +.animated-height.is-moving { + clip-path: inset(0 calc(-1 * var(--space-2))); +} + +@media (prefers-reduced-motion: reduce) { + .animated-height { + transition: none; + } +} diff --git a/src/components/AnimatedHeight.tsx b/src/components/AnimatedHeight.tsx new file mode 100644 index 000000000..a12dedc0d --- /dev/null +++ b/src/components/AnimatedHeight.tsx @@ -0,0 +1,56 @@ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { flushSync } from "react-dom"; + +import "./AnimatedHeight.css"; + +import { heightFor, RESTING, SETTLE_GRACE_MS, settled, transitionSpan, type HeightState } from "../lib/animatedHeight.ts"; +import { contentHeightFor } from "../lib/workFoldHeight.ts"; + +/** + * A box that follows its content's height through a transition (AnimatedHeight.css), so what + * opens, folds or grows inside it moves the page smoothly instead of jumping it. The content is + * measured before paint and watched for size changes after; while the box moves it clips what + * it holds. `open={false}` folds the box to nothing: the content stays drawn while the box + * slides shut and leaves the tree once it has, so a folded block costs nothing. `holdHeight` + * keeps the last height while a replacement loads, the replacement sized to it. + * Adapted from t3code's AnimatedHeight (MIT). + */ +export function AnimatedHeight({ children, open = true, holdHeight = false, animateContent = true }: { children: ReactNode; open?: boolean; holdHeight?: boolean; animateContent?: boolean }) { + const box = useRef(null); + const content = useRef(null); + const [state, setState] = useState(() => (open ? RESTING : heightFor(RESTING, null))); + // a fold keeps its content until the box has slid shut; folded, nothing is drawn or measured + const drawn = open || state.moving || state.height !== 0; + + useLayoutEffect(() => { + if (!open) { setState((current) => heightFor(current, null)); return; } + if (holdHeight) return; + const element = content.current; + if (element === null) return; + const measure = (): void => setState((current) => contentHeightFor(current, element.getBoundingClientRect().height, animateContent)); + // before paint: a box opened from nothing starts its move there, not from auto + setState((current) => heightFor(current, element.getBoundingClientRect().height)); + // a size change is reported after layout and before paint: the box moves in that same frame, + // or the paint would show the content at its new size for a frame before the move begins + const observer = new ResizeObserver(() => flushSync(measure)); + observer.observe(element); + return () => observer.disconnect(); + }, [open, holdHeight, animateContent]); + + // the transition reports the end of the move; one that never runs (reduced motion, Panel + // motion off) or never reports is given its own span, then the box rests + useEffect(() => { + if (!state.moving) return; + const element = box.current; + const style = element === null ? null : getComputedStyle(element); + const span = style === null ? 0 : transitionSpan(style.transitionDuration, style.transitionDelay); + if (span === 0) { setState(settled); return; } + const timer = window.setTimeout(() => setState(settled), span + SETTLE_GRACE_MS); + return () => window.clearTimeout(timer); + }, [state]); + + return
    { if (event.target === event.currentTarget && event.propertyName === "height") setState(settled); }}> +
    {drawn ? children : null}
    +
    ; +} diff --git a/src/components/AppCrash.css b/src/components/AppCrash.css new file mode 100644 index 000000000..1fdd2c4e7 --- /dev/null +++ b/src/components/AppCrash.css @@ -0,0 +1,46 @@ +/* the whole page after a render error reached the top (AppCrash.tsx) */ +.app-crash { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + min-height: var(--app-height, 100dvh); + padding: var(--space-4); + background: var(--bg); + color: var(--text); +} + +.app-crash-card { + width: 100%; + max-width: 520px; + margin: 0; +} + +.app-crash-title { + margin: 0 0 var(--space-2); + font-size: var(--fs-lg); + font-weight: var(--fw-semibold); + color: var(--danger-text); +} + +.app-crash-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.app-crash-details { + margin-top: var(--space-3); + font-size: var(--fs-xs); + color: var(--text-dim); +} + +.app-crash-details pre { + max-height: 40vh; + margin: var(--space-2) 0 0; + overflow: auto; + font-family: var(--font-mono); + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; +} diff --git a/src/components/AppCrash.tsx b/src/components/AppCrash.tsx new file mode 100644 index 000000000..8792918ee --- /dev/null +++ b/src/components/AppCrash.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState, type Ref } from "react"; +import { copyText } from "../lib/clipboard.ts"; +import { crashProbe, type CrashRegion } from "../lib/crashProbe.ts"; +import { t, useT } from "../lib/i18n.ts"; +import "./AppCrash.css"; + +/** The error as text a bug report can carry: its message, its stack, where and on what it ran. */ +export function crashDetails(error: unknown): string { + const head = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + const stack = error instanceof Error && error.stack ? error.stack : ""; + const trace = stack.startsWith(head) ? stack.slice(head.length).replace(/^\n/, "") : stack; + return [head, trace, `Path: ${window.location.pathname}`, `User agent: ${navigator.userAgent}`].filter((line) => line !== "").join("\n"); +} + +/** + * What the page shows when a render error reached the top of the tree (main.tsx). It sits + * outside SettingsProvider, which may be what threw, so it reads the module-level `t` (the + * language last set, English before any) and never a context. An installed web app has no + * browser reload button, so Reload app is the way back. + */ +export function AppCrash({ error }: { error: unknown }) { + const details = crashDetails(error); + const detailsRef = useRef(null); + const [copied, setCopied] = useState(null); + return ( +
    +
    +

    {t("Something went wrong")}

    +

    {t("The app hit an error and stopped drawing. Your terminals keep running in herdr.")}

    +
    + + +
    + {/* opened when copying was refused: copyText has selected the text for a long press or Ctrl+C */} +
    + {t("Details")} +
    {details}
    +
    +
    +
    + ); +} + +/** + * A part of the app (Sidebar, a pane slot, Settings) that could not be drawn; the rest goes on. + * `onShown` runs once it is drawn and again whenever the caller hands a new one, for what the part + * that threw can no longer report itself (a pane slot's terminal its connection going). + */ +export function RegionCrash({ message, retry, retryLabel, buttonRef, onShown }: { + message: string; retry: () => void; retryLabel?: string; buttonRef?: Ref; onShown?: () => void; +}) { + const t = useT(); + useEffect(() => { onShown?.(); }, [onShown]); + return ( +
    +

    {message}

    + +
    + ); +} + +/** Renders nothing; in a build with test hooks it throws where the address says (lib/crashProbe.ts). */ +export function CrashProbe({ region }: { region: CrashRegion }) { + crashProbe(region); + return null; +} diff --git a/src/components/ChatFind.css b/src/components/ChatFind.css new file mode 100644 index 000000000..a9adbc54d --- /dev/null +++ b/src/components/ChatFind.css @@ -0,0 +1,44 @@ +.chat-find { + flex: 0 0 auto; + max-height: 50%; + overflow: auto; +} + +.chat-find-options { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + margin-top: var(--space-1); + font-size: var(--fs-xs); +} + +.chat-find-options label { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.chat-find-preview { + max-height: 5lh; + overflow: auto; + margin: var(--space-2) 0 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: var(--fs-xs); + color: var(--text-dim); +} + +.chat-find-preview mark { + background: var(--accent-tint); + color: var(--text-strong); +} + +.chat-find-open { + position: absolute; + top: calc(var(--space-2) + var(--header-over, 0px)); + right: calc(var(--space-3) + var(--chat-find-menu-space, 0px)); + z-index: var(--z-popover); + background: var(--bg-panel); +} diff --git a/src/components/ChatFind.tsx b/src/components/ChatFind.tsx new file mode 100644 index 000000000..e35ba6487 --- /dev/null +++ b/src/components/ChatFind.tsx @@ -0,0 +1,99 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { ArrowDown, ArrowUp, Search, X } from "lucide-react"; +import { searchLoadedChatRows, type ChatSearchMatch, type ChatSearchRow } from "../lib/chatSearch.ts"; +import { useT } from "../lib/i18n.ts"; +import "./FindBar.css"; +import "./ChatFind.css"; + +export function ChatFind({ rows, older, olderState, onOlder, onMatch, onClose }: { + rows: readonly ChatSearchRow[]; + older: boolean; + olderState: "idle" | "loading" | "failed"; + onOlder: () => Promise; + onMatch: (match: ChatSearchMatch | null) => void; + onClose: () => void; +}) { + const t = useT(); + const input = useRef(null); + const [query, setQuery] = useState(""); + const [matchCase, setMatchCase] = useState(false); + const [chosen, setChosen] = useState<{ rowKey: string; occurrence: number } | null>(null); + const [searchingEarlier, setSearchingEarlier] = useState(false); + const [pending, setPending] = useState(false); + const priorRows = useRef(new Set()); + const requestedEarlier = useRef(false); + const mounted = useRef(true); + const result = useMemo(() => searchLoadedChatRows(rows, query, matchCase), [rows, query, matchCase]); + const inChosenRow = chosen === null ? [] : result.matches.filter((match) => match.rowKey === chosen.rowKey); + const chosenMatch = chosen === null ? undefined : inChosenRow[Math.min(chosen.occurrence, inChosenRow.length - 1)]; + const chosenIndex = chosenMatch === undefined ? -1 : result.matches.indexOf(chosenMatch); + const index = chosenIndex < 0 ? 0 : chosenIndex; + const match = result.matches[index] ?? null; + const text = match === null ? "" : rows.find((row) => row.key === match.rowKey)?.text ?? ""; + const reportMatch = useRef(onMatch); + reportMatch.current = onMatch; + useLayoutEffect(() => { input.current?.focus(); }, []); + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; }; + }, []); + useEffect(() => { + reportMatch.current(match); + }, [match?.rowKey, match?.start, match?.end, query]); + + useEffect(() => { + if (!searchingEarlier || pending) return; + const found = result.matches.filter((candidate) => !priorRows.current.has(candidate.rowKey)).at(-1); + if (found) { + setChosen({ rowKey: found.rowKey, occurrence: result.matches.filter((match) => match.rowKey === found.rowKey).indexOf(found) }); + setSearchingEarlier(false); return; + } + if (!older || (olderState === "failed" && requestedEarlier.current)) { setSearchingEarlier(false); return; } + if (olderState === "loading") return; + requestedEarlier.current = true; + setPending(true); + void onOlder().finally(() => { if (mounted.current) setPending(false); }); + }, [searchingEarlier, pending, result, older, olderState, onOlder]); + + const next = (direction: -1 | 1) => { + const match = result.matches[(index + direction + result.count) % result.count]; + if (match) setChosen({ rowKey: match.rowKey, occurrence: result.matches.filter((candidate) => candidate.rowKey === match.rowKey).indexOf(match) }); + }; + return
    { event.preventDefault(); next(1); }} + onKeyDown={(event) => { + // Safari sends the Enter that commits IME text with isComposing false and keyCode 229 + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; + if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); } + // only the field's Enter steps through matches: Enter on a button presses that button + if (event.key === "Enter" && event.target === input.current) { event.preventDefault(); next(event.shiftKey ? -1 : 1); } + }}> +
    + + {query ? result.count ? t("{current} of {total}", { current: index + 1, total: result.count }) : t("No matches") : ""} + + + +
    +
    + + {searchingEarlier + ? + : older && } +
    +

    {searchingEarlier ? t("Searching earlier messages…") : t("Matches in loaded message source, including folded content.")}

    + {olderState === "failed" &&

    {t("Couldn't load earlier messages — retry")}

    } + {match !== null &&
    +      {match.start > 80 ? "…" : ""}{text.slice(Math.max(0, match.start - 80), match.start)}{text.slice(match.start, match.end)}{text.slice(match.end, match.end + 120)}{text.length > match.end + 120 ? "…" : ""}
    +    
    } +
    ; +} diff --git a/src/components/ChatMinimap.css b/src/components/ChatMinimap.css new file mode 100644 index 000000000..da7d01bc8 --- /dev/null +++ b/src/components/ChatMinimap.css @@ -0,0 +1,58 @@ +/* the turn rail: a short dash per turn the user sent, in the margin left of the chat column */ +.chat-minimap { + position: absolute; + top: 50%; + left: var(--space-2); + z-index: var(--z-header); + width: var(--space-4); + transform: translateY(-50%); +} + +.chat-minimap-tick { + position: absolute; + left: 0; + width: 100%; + /* one step of the rail: a dense rail's ticks touch and never overlap */ + height: min(var(--space-2), calc(100% / var(--minimap-steps))); + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + transform: translateY(-50%); +} + +.chat-minimap-tick::before { + content: ""; + position: absolute; + top: 50%; + left: 0; + width: var(--space-2); + height: 1px; + background: var(--border-strong); + transition: width var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out); +} + +.chat-minimap-tick:hover::before { + width: var(--space-3); + background: var(--text-dim); +} + +.chat-minimap-tick[aria-current="true"]::before { + width: var(--space-3); + background: var(--text-strong); +} + +.chat-minimap-tick:focus-visible { + outline: var(--ring); + outline-offset: 0; + border-radius: var(--radius-sm); +} + +@media (prefers-reduced-motion: reduce) { + .chat-minimap-tick::before { transition: none; } +} + +/* a phone and a narrow window have no margin beside the column */ +@media (max-width: 768px) { + .chat-minimap { display: none; } +} diff --git a/src/components/ChatMinimap.tsx b/src/components/ChatMinimap.tsx new file mode 100644 index 000000000..1964939b7 --- /dev/null +++ b/src/components/ChatMinimap.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type RefObject } from "react"; + +import { minimapCurrent, minimapFits, minimapStep, minimapTop, MINIMAP_MIN_TURNS, type MinimapTurn } from "../lib/chatMinimap.ts"; +import { useT } from "../lib/i18n.ts"; +import "./ChatMinimap.css"; + +export interface ChatMinimapProps { + turns: readonly MinimapTurn[]; + /** the timeline's row keys in order, to tell which rows are on screen */ + rowKeys: readonly string[]; + scroller: RefObject; + onJump: (row: number) => void; +} + +const SPACING_PX = 8; + +/** A tick per turn the user sent, beside the chat column; the turn in view is marked. */ +export function ChatMinimap({ turns, rowKeys, scroller, onJump }: ChatMinimapProps) { + const t = useT(); + const [current, setCurrent] = useState(null); + const [fits, setFits] = useState(false); + const [focusIndex, setFocusIndex] = useState(null); + const ticks = useRef<(HTMLButtonElement | null)[]>([]); + + useEffect(() => { + const node = scroller.current; + if (node === null) return; + const positions = new Map(rowKeys.map((key, index) => [key, index])); + const turnRows = turns.map((turn) => turn.row); + let frame = 0; + const measure = (): void => { + frame = 0; + const view = node.getBoundingClientRect(); + let first = Infinity, last = -1; + for (const row of node.querySelectorAll("[data-chat-row]")) { + const box = row.getBoundingClientRect(); + const index = positions.get(row.dataset.chatRow ?? ""); + if (index === undefined || box.bottom <= view.top || box.top >= view.bottom) continue; + first = Math.min(first, index); last = Math.max(last, index); + } + const next = last < 0 ? null : minimapCurrent(turnRows, first, last); + setCurrent((previous) => previous === next ? previous : next); + const column = node.querySelector(".chat-transcript"); + const nextFits = column !== null && minimapFits(view.width, column.getBoundingClientRect().width); + setFits((previous) => previous === nextFits ? previous : nextFits); + }; + const schedule = (): void => { if (frame === 0) frame = requestAnimationFrame(measure); }; + schedule(); + node.addEventListener("scroll", schedule, { passive: true }); + const observer = new ResizeObserver(schedule); + observer.observe(node); + return () => { + cancelAnimationFrame(frame); + node.removeEventListener("scroll", schedule); + observer.disconnect(); + }; + }, [scroller, rowKeys, turns]); + + if (turns.length < MINIMAP_MIN_TURNS || !fits) return null; + const stop = Math.min(focusIndex ?? current ?? turns.length - 1, turns.length - 1); + const onKeyDown = (event: KeyboardEvent): void => { + const next = minimapStep(stop, turns.length, event.key); + if (next === null) return; + event.preventDefault(); + setFocusIndex(next); + ticks.current[next]?.focus(); + }; + return ; +} diff --git a/src/components/ChatTimeline.css b/src/components/ChatTimeline.css new file mode 100644 index 000000000..eb74fe1f1 --- /dev/null +++ b/src/components/ChatTimeline.css @@ -0,0 +1,57 @@ +.chat-frame { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + min-height: 0; +} + +.chat-frame-body { + position: relative; + flex: 1 1 auto; + min-height: 0; +} + +.chat-view.chat-view-virtual { + padding: 0; + overflow-anchor: none; +} + +.chat-view.chat-view-with-find { + top: 0; +} + +.chat-transcript.chat-transcript-virtual { + width: min(calc(100% - 2 * var(--space-4)), var(--chat-w)); + display: block; + min-height: 100%; + gap: 0; +} + +.chat-virtual-row { + padding-bottom: var(--space-3); +} + +.chat-virtual-row.is-before-user { + padding-bottom: calc(var(--space-3) + var(--space-5)); +} + +.chat-virtual-row[data-chat-match] { + background: var(--accent-tint); +} + +.chat-virtual-header { + padding-top: calc(var(--space-4) + var(--header-over, 0px)); +} + +.chat-virtual-header-with-find { + padding-top: var(--space-4); +} + +.chat-virtual-footer { + padding-bottom: calc(var(--space-5) + var(--composer-over, 0px)); +} + +.chat-new-messages.chat-virtual-jump { + position: absolute; +} diff --git a/src/components/ChatTimeline.tsx b/src/components/ChatTimeline.tsx new file mode 100644 index 000000000..dd53bee59 --- /dev/null +++ b/src/components/ChatTimeline.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; +import { LegendList, type LegendListRef } from "@legendapp/list/react"; + +import { ChatRowMemoryContext, type ChatRowMemory } from "../lib/chatRowState.tsx"; +import { chatMarkdownClipboardPayload } from "../lib/markdownClipboard.ts"; +// ChatTimeline.css is imported by ChatView: the frame and the loading state need it before this chunk + +export interface ChatTimelineRow { + readonly key: string; + readonly beforeUser: boolean; +} + +export interface ChatTimelineProps { + rows: readonly Row[]; + scope: string; + source: string | undefined; + scroller: RefObject; + listRef: RefObject; + following: RefObject; + /** A turn works and motion is welcome: following the end glides. */ + smoothFollow: boolean; + memories: Map; + outputChanged: () => void; + header: ReactNode; + footer: ReactNode; + label: string; + matchKey: string | null; + finding: boolean; + onScroll: () => void; + renderRow: (row: Row, index: number) => ReactNode; +} + +/** One scroll owner; selected and focused rows stay real DOM nodes outside its window. */ +export function ChatTimeline({ rows, scope, source, scroller, listRef, following, smoothFollow, memories, outputChanged, header, footer, label, matchKey, finding, onScroll, renderRow }: ChatTimelineProps) { + const [selectionKeys, setSelectionKeys] = useState([]); + const [focusKey, setFocusKey] = useState(null); + useLayoutEffect(() => { + setSelectionKeys([]); + setFocusKey(null); + }, [scope]); + const manualLayout = useRef(false); + const rowKeys = useMemo(() => rows.map((row) => row.key), [rows]); + const positions = useMemo(() => new Map(rowKeys.map((key, index) => [key, index])), [rowKeys]); + const pinned = useMemo(() => [...new Set(focusKey === null ? selectionKeys : [...selectionKeys, focusKey])], [selectionKeys, focusKey]); + const extraData = useMemo(() => ({ renderRow, matchKey }), [renderRow, matchKey]); + const attachList = useCallback((instance: LegendListRef | null) => { + listRef.current = instance; + const node = instance?.getScrollableNode(); + scroller.current = node instanceof HTMLDivElement ? node : null; + }, [listRef, scroller]); + + useLayoutEffect(() => { + manualLayout.current = false; + for (const key of memories.keys()) if (!positions.has(key)) memories.delete(key); + }, [rows, memories, positions]); + + useEffect(() => { + const node = scroller.current; + if (node === null) return; + const rowFor = (value: Node | null): string | null => { + const element = value instanceof Element ? value : value?.parentElement; + const row = element?.closest("[data-chat-row]"); + return row && node.contains(row) ? row.dataset.chatRow ?? null : null; + }; + const selectionChanged = () => { + const selection = getSelection(); + let first = rowKeys.length, last = -1; + if (selection && !selection.isCollapsed && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + for (const row of node.querySelectorAll("[data-chat-row]")) { + const index = positions.get(row.dataset.chatRow ?? ""); + if (index !== undefined && range.intersectsNode(row)) { + first = Math.min(first, index); last = Math.max(last, index); + } + } + } + const keys = last >= first ? rowKeys.slice(first, last + 1) : []; + setSelectionKeys((previous) => previous.length === keys.length && previous.every((key, index) => key === keys[index]) ? previous : keys); + }; + const focusChanged = () => setFocusKey(rowFor(document.activeElement)); + const focusOut = () => queueMicrotask(focusChanged); + const interaction = (event: Event) => { + if (event.target instanceof Element && event.target.closest("summary, .work-block-head, .work-row-head, .chat-tool-more")) manualLayout.current = true; + }; + document.addEventListener("selectionchange", selectionChanged); + node.addEventListener("focusin", focusChanged); + node.addEventListener("focusout", focusOut); + node.addEventListener("click", interaction, true); + const copy = (event: ClipboardEvent) => { + const selection = getSelection(); + if (event.defaultPrevented || !event.clipboardData || !selection) return; + const payload = chatMarkdownClipboardPayload(selection, node); + if (!payload) return; + event.clipboardData.setData("text/plain", payload.text); + event.clipboardData.setData("text/html", payload.html); + event.preventDefault(); + }; + node.addEventListener("copy", copy); + return () => { + document.removeEventListener("selectionchange", selectionChanged); + node.removeEventListener("focusin", focusChanged); + node.removeEventListener("focusout", focusOut); + node.removeEventListener("click", interaction, true); + node.removeEventListener("copy", copy); + }; + }, [scroller, positions, rowKeys]); + + // A glide aims at the end it saw; content that grew meanwhile leaves it short. Where it stops, a + // reader still following is put at the end at once. + useEffect(() => { + const node = scroller.current; + if (node === null || !smoothFollow) return; + const settle = () => { + if (following.current && !manualLayout.current && node.scrollHeight - node.clientHeight - node.scrollTop > 1) listRef.current?.scrollToEnd({ animated: false }); + }; + // `in` narrows the element to never: the DOM types always declare onscrollend + if ("onscrollend" in (node as object)) { + node.addEventListener("scrollend", settle); + return () => node.removeEventListener("scrollend", settle); + } + // Safari before 26 fires no scrollend: settle once the scroll events have been quiet a moment + let quiet: ReturnType | undefined; + const scrolled = () => { clearTimeout(quiet); quiet = setTimeout(settle, 150); }; + node.addEventListener("scroll", scrolled, { passive: true }); + return () => { clearTimeout(quiet); node.removeEventListener("scroll", scrolled); }; + }, [scroller, listRef, following, smoothFollow]); + + useLayoutEffect(() => { + const transcript = scroller.current?.querySelector(".chat-transcript"); + if (transcript) { + if (source === undefined) delete transcript.dataset.conversationSource; + else transcript.dataset.conversationSource = source; + } + }, [source, scroller]); + + const rendered = useCallback(({ item, index }: { item: Row; index: number }) => { + let memory = memories.get(item.key); + if (!memory) { + memory = { toggles: new Map(), outputs: new Map(), outputChanged }; + memories.set(item.key, memory); + } + return +
    {renderRow(item, index)}
    +
    ; + }, [memories, outputChanged, renderRow, matchKey]); + + return row.key} + renderItem={rendered} + estimatedItemSize={120} + drawDistance={400} + recycleItems={false} + initialScrollAtEnd + maintainVisibleContentPosition + alwaysRender={{ keys: pinned }} + ListHeaderComponent={
    {header}
    } + ListFooterComponent={
    {footer}
    } + onScroll={onScroll} + onLoad={() => { if (following.current) listRef.current?.scrollToEnd({ animated: false }); }} + onItemSizeChanged={() => { + if (!following.current || manualLayout.current) return; + // The browser's own glide yields to any scroll the reader or a jump makes next; the list's + // animated scroll keeps retrying toward the end and would pull them back. + const node = scroller.current; + if (smoothFollow && node !== null) node.scrollTo({ top: node.scrollHeight - node.clientHeight, behavior: "smooth" }); + else listRef.current?.scrollToEnd({ animated: false }); + }} + />; +} diff --git a/src/components/ChatView.css b/src/components/ChatView.css index 7ba722587..6fc04dd90 100644 --- a/src/components/ChatView.css +++ b/src/components/ChatView.css @@ -71,6 +71,10 @@ background: linear-gradient(var(--bg), transparent); pointer-events: none; } + .terminal-host:first-child .chat-view-virtual::before { + top: 0; + margin: 0 0 calc(-1 * var(--space-5)); + } } .chat-transcript { @@ -132,6 +136,44 @@ overflow-wrap: anywhere; } +/* a long message (lib/userMessageFold.ts) is capped at --chat-fold-lines lines of its prose... */ +.chat-bubble.is-capped .chat-bubble-body { + max-height: calc(var(--chat-fold-lines) * var(--chat-fs-body) * var(--lh-prose)); + overflow: hidden; +} + +/* ...and where the cap cuts it (ChatView.tsx measures that), its last line fades into the card + through an alpha mask (the gradient's colour is only its opaque end), so the cut reads as a + fold behind Show full message and not as the end of what was said */ +.chat-bubble.is-folded .chat-bubble-body { + mask-image: linear-gradient(currentColor calc(100% - var(--chat-fs-body) * var(--lh-prose)), transparent); +} + +/* the fold's control on the card's right edge, a quiet text button in the meta register; its + padding overhangs the text edge as a work row's head does */ +.chat-bubble-foot { + display: flex; + justify-content: flex-end; + margin-top: var(--space-1); +} + +.chat-bubble-fold { + min-height: var(--chip-h); + margin-right: calc(-1 * var(--space-1)); + padding: 0 var(--space-1); + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-dim); + font-size: var(--fs-xs); + cursor: pointer; +} + +.chat-bubble-fold:hover { + background: var(--bg-hover); + color: var(--text-strong); +} + /* meta rows fade in on hover / focus: the transcript reads as text, not as controls. While unseen they take no click or tap; the keyboard still reaches them, which shows them */ .chat-turn-meta { @@ -192,6 +234,12 @@ margin-left: var(--space-1); } +/* a turn's time takes focus so a keyboard reaches its full-date tooltip */ +.chat-time:focus-visible { + outline: var(--ring); + outline-offset: var(--ring-offset); +} + /* "MD" / "TXT": a copy glyph and a two-letter format, like Codex's copy control */ .chat-meta-btn { display: inline-flex; @@ -282,6 +330,56 @@ height: auto; } +/* an image holds its box until it decodes (Markdown.tsx ReservedImage, lib/imageBox.ts): the wrapper + is the box while the bytes arrive and after a failure, and gives it up once the picture is in, so + the image renders bare and nothing around it is laid out anew */ +.image-slot { display: contents; } + +.image-slot.is-loading, +.image-slot.is-failed { + position: relative; + display: inline-block; + max-width: 100%; + overflow: hidden; + background: var(--bg-elevated); +} + +/* the picture loads behind its slot and shows only once it is whole */ +.image-slot.is-loading > img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + visibility: hidden; +} + +.image-slot-note { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-1); + padding: var(--space-2); + color: var(--text-dim); + font-size: var(--fs-xs); + text-align: center; + overflow-wrap: anywhere; +} + +.image-slot-note > svg { width: 1.25em; height: 1.25em; flex: none; } + +/* a standalone Markdown image (a screenshot, a logo on a line of its own) holds a 16:9 slot as wide + as the lane, up to the bound lib/imageBox.ts puts on it, framed as a code block is */ +.markdown .image-slot.is-loading, +.markdown .image-slot.is-failed { + width: 100%; + aspect-ratio: 16 / 9; + border: var(--hairline) solid var(--border); + border-radius: var(--radius-lg); +} + /* a README's centered logo and badges (lib/markdownHtml.ts) */ .markdown .markdown-center { text-align: center; @@ -560,13 +658,17 @@ width: 100%; display: flex; flex-direction: column; - gap: var(--space-1); + /* the folded rule is an inset shadow in the padding that holds its place: both arrive and leave + at the pace of the rows' move (AnimatedHeight.css), never a frame before it, or a reader + pinned at the end would be pulled up by the block's momentary shrink */ + box-shadow: inset 0 calc(-1 * var(--hairline)) 0 0 transparent; + transition: padding-bottom var(--dur-base) var(--ease-out), box-shadow var(--dur-base) var(--ease-out); } /* a folded block is one row with the exchange's one rule under it, where it closes the work */ .work-block.is-folded { - padding-bottom: var(--space-1); - border-bottom: var(--hairline) solid var(--border); + padding-bottom: calc(var(--space-1) + var(--hairline)); + box-shadow: inset 0 calc(-1 * var(--hairline)) 0 0 var(--border); } .work-block-head, @@ -664,10 +766,12 @@ white-space: nowrap; } -/* rows hang under the header on its own left edge */ +/* rows hang under the header on its own left edge. The space under the header is the rows' own + (not the block's gap): it opens and folds with them, and a folded block holds none of it */ .work-block-rows { display: flex; flex-direction: column; + padding-top: var(--space-1); } .work-row { @@ -902,6 +1006,7 @@ .work-block-head, .work-row-head, .chat-copy, + .chat-bubble-fold, .markdown-code-copy, .markdown-code-more { min-height: var(--touch-target); @@ -950,7 +1055,8 @@ @media (prefers-reduced-motion: reduce) { .chat-turn-meta, - .markdown-code-header { transition: none; } + .markdown-code-header, + .work-block { transition: none; } .work-block.is-live .work-block-title::before { animation: none; } } @@ -1010,8 +1116,34 @@ /* a file path in an answer: opens the file viewer, reads as the text or code it is. Its dotted underline is the affordance; the accent comes under the pointer or the focus. It breaks as the text around it does: anywhere in a paragraph, between words in a table cell */ +/* a skill's name in an answer, in the form it is typed: the raw text stays in the node, so a copy yields it */ +.markdown-skill { + display: inline-block; + padding: 0 var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--accent-tint); + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.9em; + line-height: 1.5; + white-space: nowrap; +} + +.markdown-skill svg { + width: 1em; + height: 1em; + margin-right: 0.3em; + vertical-align: -0.12em; + color: var(--accent); + user-select: none; +} + .markdown-file { display: inline; + -webkit-user-select: text; + user-select: text; + -webkit-touch-callout: default; padding: 0; border: 0; background: none; @@ -1130,14 +1262,18 @@ cursor: zoom-in; } -/* a fixed box: a lazy thumbnail must not grow the bubble (and shift the scroll) when it loads */ -.chat-user-image img { +/* a fixed box: a lazy thumbnail must not grow the bubble (and shift the scroll) when it loads, and + its slot is the same box while it loads or after it failed (Markdown.tsx ReservedImage) */ +.chat-user-image img, +.chat-user-image .image-slot.is-loading, +.chat-user-image .image-slot.is-failed { display: block; width: min(9rem, 38vw); height: 7rem; - object-fit: cover; } +.chat-user-image img { object-fit: cover; } + /* where a compaction folded the conversation: a quiet divider, its summary on request */ .chat-compact { margin: var(--space-4) 0; diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 23d07c6df..e5a501322 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1,21 +1,35 @@ import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentType } from "react"; import { createPortal } from "react-dom"; import { - ArrowDown, BookOpen, Check, ChevronDown, ChevronRight, Circle, CircleAlert, CircleCheck, CircleDot, CircleSlash, CircleX, Copy, Layers, Target, + ArrowDown, BookOpen, Check, ChevronDown, ChevronRight, Circle, CircleAlert, CircleCheck, CircleDot, CircleSlash, CircleX, Copy, Layers, Search, Target, type LucideProps, } from "lucide-react"; import "./ChatView.css"; +// the timeline's styles stay in the entry: its chunk loads apart (chatTimelineLoader.ts), but +// .chat-frame and the loading state are drawn before it arrives +import "./ChatTimeline.css"; import { AgentMark } from "./AgentMark.tsx"; -import { Markdown } from "./Markdown.tsx"; +import { AnimatedHeight } from "./AnimatedHeight.tsx"; +import { Markdown, ReservedImage } from "./Markdown.tsx"; +import { ImageLightbox } from "./ImageLightbox.tsx"; import { PromptCard } from "./PromptCard.tsx"; import { RenderBoundary } from "./RenderBoundary.tsx"; +import { loadChatTimeline, loadedChatTimeline, useChatTimeline } from "./chatTimelineLoader.ts"; +import { ChatFind } from "./ChatFind.tsx"; +import { ChatMinimap } from "./ChatMinimap.tsx"; +import { minimapTurns } from "../lib/chatMinimap.ts"; +import { chatSearchText, type ChatSearchMatch } from "../lib/chatSearch.ts"; +import type { LegendListRef } from "@legendapp/list/react"; +import { appendChatRowSlots, createChatRowSlots, prependChatRowSlots } from "../lib/chatRows.ts"; +import { ChatDetails, ChatRowMemoryContext, useChatRowToggle, type ChatRowMemory } from "../lib/chatRowState.tsx"; import { turnRevision } from "../lib/turnRevision.ts"; import { useWholeOutput as useScopedOutput } from "../lib/useWholeOutput.ts"; import { turnSkills } from "../lib/skillActivity.ts"; import { ApiError } from "../lib/api.ts"; import { copyText } from "../lib/clipboard.ts"; +import { attachmentFileName, mentionFileName, type GalleryImage } from "../lib/imageViewer.ts"; import { useMachineApi } from "../lib/machineContext.tsx"; import { toTranscriptMessages, type TranscriptMessage } from "../lib/transcript.ts"; import { isLiveWorkTurn, isWaitingWorkTurn, formatWorkDuration, splitTurn, workFailed, workStartsOpen, workSummary, type ToolPart as ToolPartType } from "../lib/workBlocks.ts"; @@ -23,7 +37,9 @@ import { phaseRows, planRows, taskRows, todoRows, type ChecklistRow } from "../l import { isTodoTool, parseTodoAnswer, todoCallSummary, type TodoItem, type TodoStatus } from "../lib/todos.ts"; import { formatGoalTime, turnGoal, type GoalState, type GoalStatus } from "../lib/goals.ts"; import { useSettings } from "../lib/settings.ts"; -import { pageScrollDistance } from "../lib/pageScroll.ts"; +import { PAGE_OVERLAP_ROWS, pageScrollDistance } from "../lib/pageScroll.ts"; +import { followGlides } from "../lib/followScroll.ts"; +import { useMediaQuery } from "../lib/useMediaQuery.ts"; import { statusEdgeRead } from "../lib/status.ts"; import { ConversationRefresh } from "../lib/conversationRefresh.ts"; import { usePageVisible } from "../lib/visibility.ts"; @@ -32,22 +48,46 @@ import { useFacesArrived } from "../lib/fontFaces.ts"; import { OpenFileContext } from "../lib/filePaths.ts"; import { linkFilePaths } from "./FilePathLink.tsx"; import { patchText } from "../../shared/patch.ts"; -import { toolVerb } from "../lib/toolVerbs.ts"; -import { machinePath } from "../../shared/machines.ts"; +import { workRowLabel } from "../lib/workRowLabel.ts"; +import { machinePath, paneStorageId } from "../../shared/machines.ts"; +import { chatRestoreIndex, chatTurnAnchor, readChatPosition, rememberChatPosition, type ChatPosition } from "../lib/chatScrollMemory.ts"; import { fileUrl } from "../lib/api.ts"; import { useMachineId } from "../lib/machineContext.tsx"; +import { useSkillMatcher } from "../lib/useSkillMatcher.ts"; +import type { SkillMatcher } from "../lib/skillMentions.ts"; import { lineDiff } from "../lib/diff.ts"; import { formatTokens, sameMessage } from "../lib/compose.ts"; import { formatElapsed, taskCallItems, taskResultMarkdown } from "../lib/omoTasks.ts"; import { sendUserFileCall } from "../lib/sendUserFile.ts"; +import { shouldFoldUserMessage } from "../lib/userMessageFold.ts"; /** The pane this chat shows, for what its rows fetch on request (a tool call's whole output). */ const ChatPaneContext = createContext(null); const ChatHistoryContext = createContext(""); +/** + * Opens the image viewer for a message's pictures. The viewer is held by ChatView, not by the row: + * a virtualized row unmounts once it scrolls out of the render window (the transcript follows new + * output while the viewer is open), and it must not take the open viewer with it. + */ +const ImageViewerContext = createContext<(images: GalleryImage[], index: number) => void>(() => {}); import type { TypedAnswer } from "../lib/promptAnswer.ts"; import type { AgentStatus, ConversationMetadata, ConversationPart, ConversationResponse, ConversationTurn, InteractivePrompt, OmoTaskResult } from "../../shared/protocol.ts"; import { chatIsBlank, type ChatRead } from "../lib/greeting.ts"; -import { currentLocale, useT } from "../lib/i18n.ts"; +import { TurnTime } from "./TurnTime.tsx"; +import { isMacPlatform } from "../lib/shortcuts.ts"; +import { useT } from "../lib/i18n.ts"; + +// No row is this object, so the list finds nothing to scroll to. +const NO_ROW = Object.freeze({}); +/** + * The list's scrollToEnd does not scroll at once: it waits for the list's next commit, and then + * for the layout to settle after a data change (up to 800 ms). A follow asked for while the reader + * was at the end can so land after the reader paged or scrolled away, and pull them back. Any newer + * scroll request supersedes a waiting one; a request for no row supersedes it without moving. + */ +function dropPendingListScroll(list: LegendListRef | null): void { + void list?.scrollToItem({ item: NO_ROW, animated: false }); +} const TRANSCRIPT_LINES = 400; const PROMPT_POLL_MS = 2000; @@ -56,6 +96,10 @@ const LOAD_OLDER_PX = 400; export interface ChatViewProps { paneId: string; + /** Only the active pane owns the chat-local Mod+F shortcut. */ + active?: boolean; + /** false while the pane sits off-tab (a cached or retained pane): no reads, no conversation-watch */ + visible?: boolean; pageScroll?: { current: ((direction: -1 | 1) => void) | null }; refreshKey: number; /** bumped by a conversation push; coalesced at the former 2s polling cadence */ @@ -107,13 +151,12 @@ interface ChatState { const EMPTY_STATE: ChatState = { source: "conversation", turns: [], messages: [], truncated: false }; - -function formatTime(ts: string | null): string | null { - if (ts === null) return null; - const date = new Date(ts); - return Number.isNaN(date.getTime()) ? null : date.toLocaleTimeString(currentLocale(), { hour: "2-digit", minute: "2-digit" }); +/** The view of a structured (not scrollback) answer. */ +function conversationState(conversation: ConversationResponse): ChatState { + return { source: "conversation", conversationSource: conversation.source, turns: conversation.turns, messages: [], truncated: false, activity: conversation.activity }; } + function plainText(markdown: string): string { return markdown .replace(/```[^\n]*\n([\s\S]*?)```/g, "$1") @@ -278,7 +321,35 @@ function useWholeOutput(ref: string | undefined): { text: string | null; state: const machineId = useMachineId(); const history = useContext(ChatHistoryContext); const url = ref === undefined || paneId === null ? null : machinePath(machineId, `pane/conversation/tool-output?${new URLSearchParams({ pane_id: paneId, ref }).toString()}`); - return useScopedOutput(url, history); + const memory = useContext(ChatRowMemoryContext); + const saved = ref === undefined ? undefined : memory?.outputs.get(ref); + const result = useScopedOutput(saved === undefined ? url : null, history); + useLayoutEffect(() => { + if (ref !== undefined && result.text !== null && memory && memory.outputs.get(ref) !== result.text) { + memory.outputs.set(ref, result.text); + memory.outputChanged(); + } + }, [ref, memory, result.text]); + return saved === undefined ? result : { text: saved, state: "idle", load: result.load }; +} + +/** A chat thumbnail: a tap opens the message's pictures over the page (ImageLightbox), this one first. */ +function ImageThumb({ image, onOpen }: { image: GalleryImage; onOpen: () => void }) { + const t = useT(); + // the focus is placed on the thumbnail before the viewer opens, so the viewer's focus trap + // records it as the opener and gives the focus back here on close (Safari does not focus a + // clicked button on its own) + return ; +} + +/** The thumbnails of a message's pictures; a press opens ChatView's viewer on them. */ +function ImageGallery({ images, className }: { images: GalleryImage[]; className: string }) { + const openViewer = useContext(ImageViewerContext); + return
    + {images.map((image, index) => openViewer(images, index)} />)} +
    ; } /** Images a tool returned (pi reads a picture into the result); opened with the row. */ @@ -286,10 +357,12 @@ function ToolImages({ paneId, part }: { paneId: string; part: ToolPartType }) { const t = useT(); const machineId = useMachineId(); if (part.images === undefined || part.images.length === 0) return null; - return
    {part.images.map((image) => { - const src = machinePath(machineId, `pane/conversation/image?${new URLSearchParams({ pane_id: paneId, ref: image.ref }).toString()}`); - return {t("Attached; - })}
    ; + const images = part.images.map((image, index): GalleryImage => ({ + src: machinePath(machineId, `pane/conversation/image?${new URLSearchParams({ pane_id: paneId, ref: image.ref }).toString()}`), + name: t("Attached image"), + file: attachmentFileName(image.media_type, index), + })); + return ; } /** @@ -297,26 +370,27 @@ function ToolImages({ paneId, part }: { paneId: string; part: ToolPartType }) { * verb table knows reads as verb + object, and its id moves to the title and the opened detail; * any other keeps its id in front, as the agent names it. */ -function WorkRow({ paneId, part }: { paneId: string; part: ToolPartType }) { +function WorkRow({ paneId, part, stateKey }: { paneId: string; part: ToolPartType; stateKey: string }) { const t = useT(); // the files an output names open from it ("saved /tmp/shot/t0.png") const openFile = useContext(OpenFileContext); - const [open, setOpen] = useState(false); + const [chosen, setOpen] = useChatRowToggle(stateKey, false); + const open = chosen === true; const whole = useWholeOutput(part.output_ref); const summary = part.arguments_omitted ? t("Arguments omitted (too large)") : todoCallSummary(part) ?? part.summary; // reads the call's input: once per call, not on every poll of the transcript - const verb = useMemo(() => toolVerb({ name: part.name, input: part.input }, summary), [part.name, part.input, summary]); + const { verb, narration } = useMemo(() => workRowLabel({ name: part.name, input: part.input }, summary), [part.name, part.input, summary]); // the list is the answer of a todo call: its raw text would say it twice const output = isTodoTool(part.name) && parseTodoAnswer(part.output) !== null ? "" : whole.text ?? part.output; return
    - {open &&
    {verb !== null &&

    {part.name}

    }{output.length > 0 &&

    {t(part.error ? "Error" : "Output")}

    {linkFilePaths(output, openFile)}
    {part.output_ref !== undefined && whole.text === null &&
    ; } -function ThinkingRow({ text }: { text: string }) { +function ThinkingRow({ text, stateKey }: { text: string; stateKey: string }) { const t = useT(); - const [open, setOpen] = useState(false); + const [chosen, setOpen] = useChatRowToggle(stateKey, false); + const open = chosen === true; return
    - {open &&
    {visible.map((part, index) => - part.kind === "thinking" ? - : part.kind === "text" ?
    {part.text}
    - : part.kind === "tool" ? : null)}
    } + {/* the rows open and fold at the block's measured height; folded and settled, they are not drawn */} + +
    {visible.map((part, index) => + part.kind === "thinking" ? + : part.kind === "text" ?
    {part.text}
    + : part.kind === "tool" ? : null)} +
    +
    ; } @@ -376,7 +455,7 @@ function SkillActivityList({ parts }: { parts: ConversationPart[] }) { const skills = turnSkills(parts); if (skills.length === 0) return null; return
    - {skills.map((skill) =>
    + {skills.map((skill) =>
    )} + )}
    ; } @@ -399,7 +478,7 @@ function GoalActivity({ goal }: { goal: GoalState }) { goal.timeUsedSeconds !== null && goal.timeUsedSeconds > 0 ? formatGoalTime(goal.timeUsedSeconds) : null, goal.tokensUsed !== null && goal.tokensUsed > 0 ? t("{n} tokens", { n: formatTokens(goal.tokensUsed) }) : null, ].filter((item) => item !== null).join(" · "); - return
    + return
    @@ -407,7 +486,7 @@ function GoalActivity({ goal }: { goal: GoalState }) { {goal.blockedReason !== null &&

    {goal.blockedReason}

    } {spent.length > 0 &&

    {t("Used so far: {spent}", { spent })}

    }
    -
    ; + ; } /** Image files a message mentions as `@path`, the way this app attaches them: shown as thumbnails. */ @@ -416,21 +495,74 @@ const IMAGE_MENTION = /(?:^|\s)@(\S+\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi; function UserImages({ paneId, parts, text }: { paneId: string; parts: ConversationPart[]; text: string }) { const t = useT(); const machineId = useMachineId(); - const open = useContext(OpenFileContext); const pasted = parts.filter((part): part is Extract => part.kind === "image"); const mentioned = [...new Set([...text.matchAll(IMAGE_MENTION)].map((match) => match[1]!))]; if (pasted.length === 0 && mentioned.length === 0) return null; - return
    - {pasted.map((part) => { - const src = machinePath(machineId, `pane/conversation/image?${new URLSearchParams({ pane_id: paneId, ref: part.ref }).toString()}`); - return {t("Attached; - })} - {mentioned.map((path) => { - const src = fileUrl(path, paneId, machineId); - return open !== null - ? - : {path}; - })} + // one gallery for the message: its pasted pictures first, then the files it mentioned + const images: GalleryImage[] = [ + ...pasted.map((part, index): GalleryImage => ({ + src: machinePath(machineId, `pane/conversation/image?${new URLSearchParams({ pane_id: paneId, ref: part.ref }).toString()}`), + name: t("Attached image"), + file: attachmentFileName(part.media_type, index), + })), + ...mentioned.map((path): GalleryImage => ({ src: fileUrl(path, paneId, machineId), name: path, file: mentionFileName(path) })), + ]; + return ; +} + +/** + * The user's words as a raised card. A long message (lib/userMessageFold.ts) is capped at the + * lines `--chat-fold-lines` names; where the cap cuts it (measured on that message alone, never on + * every row) the last line fades into the card and "Show full message" opens it. Opened, it stays + * open while its row leaves and re-enters the list (useChatRowToggle), and Find landing on the + * turn opens it, so a match is never left behind the fold. + */ +function UserMessage({ text, matched }: { text: string; matched: boolean }) { + const t = useT(); + const canFold = useMemo(() => shouldFoldUserMessage(text), [text]); + const [chosen, setOpen] = useChatRowToggle("fold", false); + const open = chosen === true; + const capped = canFold && !open; + const body = useRef(null); + const [clipped, setClipped] = useState(false); + useLayoutEffect(() => { + const node = body.current; + if (!capped || node === null) { setClipped(false); return; } + const measure = (): void => setClipped(node.scrollHeight > node.clientHeight); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => observer.disconnect(); + }, [capped]); + // Find arriving on this turn opens a cut message once; it does not hold it open, so Show less + // still works while the match stays on the row + const revealed = useRef(false); + useEffect(() => { + if (!matched) { revealed.current = false; return; } + if (revealed.current || !clipped) return; + revealed.current = true; + setOpen(true); + }, [matched, clipped, setOpen]); + const card = useRef(null); + const folding = useRef(false); + const toggle = (): void => { + folding.current = open; + setOpen(!open); + }; + // "Show less" sits at the bottom of a long message: after folding, bring the card's top back + // into view rather than leave the reader far below it (as a code block's fold does) + useLayoutEffect(() => { + if (!folding.current) return; + folding.current = false; + const node = card.current; + const view = node?.closest(".chat-view"); + if (node && view && node.getBoundingClientRect().top < view.getBoundingClientRect().top) node.scrollIntoView({ block: "start" }); + }, [open]); + return
    +
    {text}
    + {(open || clipped) &&
    + +
    }
    ; } @@ -442,6 +574,10 @@ interface TurnProps { /** ...and "Needs you" while the agent is blocked */ waiting: boolean; showThinking: boolean; + /** the pane's skill names: an answer's text chips them */ + skills?: SkillMatcher | null; + /** Find's current match is this turn: a folded message opens so the match can be read */ + matched?: boolean; } const TASK_RESULT_ICONS: Record> = { completed: CircleCheck, failed: CircleX, cancelled: CircleSlash }; @@ -458,8 +594,8 @@ function TaskResultRow({ task }: { task: OmoTaskResult }) { task.tokens === null || task.tokens === 0 ? null : t("{n} tokens", { n: formatTokens(task.tokens) }), ].filter((item) => item !== null).join(" · "); // the answer is drawn once the row is opened: a wake can carry many tasks, each with a long answer - const [opened, setOpened] = useState(false); - return
    { if (event.currentTarget.open) setOpened(true); }}> + const [opened, setOpened] = useChatRowToggle(`task-loaded:${task.id}`, false); + return { if (event.currentTarget.open) setOpened(true); }}>
    } - ; + ; } /** what a runtime notice was: a background result by name, anything else by its own first line */ @@ -488,36 +624,36 @@ function noticeLabel(t: ReturnType, notice: Extract{t("Couldn't copy. Select the text and copy it manually.")}

    ; const copied = (ok: boolean): void => setCopyFailed(!ok); - const time = formatTime(turn.ts); + const time = turn.ts === null ? null : ; const compact = turn.parts.find((part): part is Extract => part.kind === "compact"); if (compact !== undefined) { - return
    - {t("Conversation compacted")}{time !== null && <> · } + return + {t("Conversation compacted")}{time !== null && <> · {time}}
    {compact.text}
    -
    ; + ; } // OmO's background tasks reported back: a card of what ended, each result on request const ended = turn.parts.find((part): part is Extract => part.kind === "task_result"); if (ended !== undefined) { const heading = ended.tasks.length === 1 ? t("Background task ended") : t("{n} background tasks ended", { n: ended.tasks.length }); return
    -

    +

    {ended.tasks.map((task) => )}
    ; } // the runtime spoke, not the user: a quiet divider like a compaction, the text on request const notice = turn.parts.find((part): part is Extract => part.kind === "notice"); if (notice !== undefined) { - return
    - {noticeLabel(t, notice)}{time !== null && <> · } + return + {noticeLabel(t, notice)}{time !== null && <> · {time}}
    {notice.text}
    -
    ; + ; } if (turn.role === "user") { const text = turn.parts.filter((part): part is Extract => part.kind === "text").map((part) => part.text).join("\n\n"); @@ -525,8 +661,8 @@ const Turn = memo(function Turn({ paneId, turn, live, waiting, showThinking }: T {/* one row: with a mouse the time and copy sit beside the bubble, on touch under it */}
    - {text.length > 0 &&
    {text}
    } -
    {time !== null && }{text.length > 0 && }
    + {text.length > 0 && } +
    {time}{text.length > 0 && }
    {copyError} {/* the skill this message invoked (omp, omo, pi): the runtime recorded its instructions with it */} @@ -540,12 +676,12 @@ const Turn = memo(function Turn({ paneId, turn, live, waiting, showThinking }: T {goal !== null && } {work.length > 0 && } - {answer.map((part, index) => {part.text})} + {answer.map((part, index) => {part.text})} {answerText.length > 0 &&
    {/* a mouse reads one copy glyph and the words "Plain text"; touch reads the two formats */} MD TXT{t("Plain text")} - {time !== null && } + {time}
    } {copyError} ; @@ -558,13 +694,12 @@ function FallbackTurn({ paneId, message }: { paneId: string; message: Transcript } /** Optional terminal output must never hold initial display or transcript polling. */ -function TerminalFallback({ paneId }: { paneId: string }) { +function TerminalFallback({ paneId, visible }: { paneId: string; visible: boolean }) { const { fetchPaneTranscript } = useMachineApi(); const t = useT(); const [open, setOpen] = useState(false); const [text, setText] = useState(null); const [error, setError] = useState(null); - const visible = usePageVisible(); useEffect(() => { if (!open || !visible) return; let cancelled = false; @@ -593,12 +728,15 @@ function TerminalFallback({ paneId }: { paneId: string }) { } // the app re-renders on every pane-status and poll; an unchanged transcript sits those out -export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, invalidationKey = 0, onConversationWatch, conversationPushes = false, onTerminalOutput, sentKey = 0, connected, ended, agent, agentStatus, onMetadata, onRead, greeted = false, onPrompt, onSuggestion, onRestored, promptRefreshKey = 0, pendingAnswer = null, onPendingAnswerDone, promptDock = null, onPromptAnswered }: ChatViewProps) { +export const ChatView = memo(function ChatView({ paneId, active = true, visible: paneVisible = true, pageScroll, refreshKey, invalidationKey = 0, onConversationWatch, conversationPushes = false, onTerminalOutput, sentKey = 0, connected, ended, agent, agentStatus, onMetadata, onRead, greeted = false, onPrompt, onSuggestion, onRestored, promptRefreshKey = 0, pendingAnswer = null, onPendingAnswerDone, promptDock = null, onPromptAnswered }: ChatViewProps) { const t = useT(); - const { fetchPaneConversation, fetchPanePromptState, fetchPaneTranscript } = useMachineApi(); + const { fetchPaneConversation, peekPaneConversation, fetchPanePromptState, fetchPaneTranscript } = useMachineApi(); const { settings } = useSettings(); - // polls pause while the page is hidden and pick up at once when it is back - const visible = usePageVisible(); + const machineId = useMachineId(); + const skillMatcher = useSkillMatcher(paneId); + // polls pause while the page or the pane is hidden and pick up at once when it is back + const pageVisible = usePageVisible(); + const visible = paneVisible && pageVisible; /** the answer last laid out: an unchanged poll (a 304) hands back this very object */ const lastAnswer = useRef(null); const [conversationRefresh] = useState(() => new ConversationRefresh()); @@ -608,12 +746,23 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, // Native Grok/Agy events can arrive while Herdr still reports ready/done. This is display // feedback only: sending, queuing and stopping retain the server's input authority. const activity = state.activity ?? (agentStatus === "working" ? "working" : "idle"); + const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); + const smoothFollow = followGlides({ working: activity !== "idle" && agentStatus !== "blocked", reducedMotion }); const workStatus = state.activity !== undefined && agentStatus !== "blocked" ? activity === "idle" ? "idle" : "working" : agentStatus; const [error, setError] = useState(null); const [errorStatus, setErrorStatus] = useState(null); const [newMessages, setNewMessages] = useState(false); /** scrolled up from the end: the way back is offered even when nothing new came */ const [away, setAway] = useState(false); + const [findOpen, setFindOpen] = useState(false); + const [viewer, setViewer] = useState<{ images: GalleryImage[]; index: number } | null>(null); + const openViewer = useCallback((images: GalleryImage[], index: number) => setViewer({ images, index }), []); + const closeViewer = useCallback(() => setViewer(null), []); + const [matchKey, setMatchKey] = useState(null); + const findButton = useRef(null); + const wasFinding = useRef(false); + const [outputVersion, setOutputVersion] = useState(0); + const outputChanged = useCallback(() => setOutputVersion((version) => version + 1), []); /** an answer for this pane arrived (or failed): until then, and while a replaced history is read again, an empty chat is only loading */ const [loaded, setLoaded] = useState(false); const [prompt, setPrompt] = useState(null); @@ -639,6 +788,8 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, sentKeyRef.current = sentKey; const [promptPollKey, setPromptPollKey] = useState(0); const scroller = useRef(null); + const timeline = useRef(null); + const ChatTimeline = useChatTimeline(); const stickToBottom = useRef(true); // Explicit paging stays away even inside the ordinary near-bottom tolerance. const pagingAway = useRef(false); @@ -647,6 +798,9 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, // Older pages sit above the newest one. Once any shows, the newest page is // polled from its start at that moment, so the two always meet. const [older, setOlder] = useState([]); + const olderCount = useRef(0); + const incarnation = useRef(0); + const slots = useRef(createChatRowSlots({ machineId, paneId, historyId: null, incarnation: 0 }, 0)); /** the page before everything shown; null at the conversation's beginning, undefined when unknown */ const [olderCursor, setOlderCursor] = useState(undefined); const [olderState, setOlderState] = useState<"idle" | "loading" | "failed">("idle"); @@ -657,7 +811,10 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, const history = useRef(undefined); const [historyId, setHistoryId] = useState(undefined); const shownPane = useRef(paneId); - const prepended = useRef<{ top: number; height: number } | null>(null); + const prependAnchor = useRef<{ key: string; offset: number } | null>(null); + /** where the reader left this pane before (lib/chatScrollMemory.ts), until the first answer puts it back */ + const restoring = useRef(null); + const stopRestore = useRef<(() => void) | null>(null); const [pollKey, setPollKey] = useState(0); /** * The assistant turn that was last when a message went out, with the turns it was read from. @@ -670,21 +827,98 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, const seenSent = useRef(sentKey); const seenStatus = useRef<{ pane: string; status: AgentStatus | undefined }>({ pane: paneId, status: agentStatus }); const seenConnected = useRef(connected); + /** the socket has been connected since this pane showed: only then is a connected edge a reconnect */ + const wasConnected = useRef(connected); const seenInvalidation = useRef({ pane: paneId, key: invalidationKey }); const dropOlder = (): void => { olderGeneration.current += 1; loadingOlder.current = false; - heldFrom.current = null; prepended.current = null; setOlder([]); setOlderCursor(undefined); setOlderState("idle"); + heldFrom.current = null; olderCount.current = 0; setOlder([]); setOlderCursor(undefined); setOlderState("idle"); + incarnation.current += 1; + prependAnchor.current = null; + slots.current = createChatRowSlots({ machineId, paneId, historyId: history.current ?? null, incarnation: incarnation.current }, 0); + setFindOpen(false); setMatchKey(null); }; - useEffect(() => { + /** What an answer says beside its turns: where older pages start, its metadata, abandoned turns. */ + const showAnswerDetails = (conversation: ConversationResponse): void => { + if (heldFrom.current === null) setOlderCursor(conversation.source === "scrollback" ? undefined : conversation.cursor); + onMetadataRef.current?.(paneId, conversation.source === "scrollback" ? null : conversation.metadata ?? null); + setAbandoned(conversation.abandoned ?? null); + }; + + /** Lays out the state built from an answer and marks that answer as the one shown. */ + const showAnswer = (conversation: ConversationResponse, next: ChatState): void => { + if (next.source === "conversation") { + const count = olderCount.current + next.turns.length; + if (slots.current.scope.historyId !== (history.current ?? null) || lastAnswer.current?.source === "scrollback") { + slots.current = createChatRowSlots({ machineId, paneId, historyId: history.current ?? null, incarnation: ++incarnation.current }, count); + } else if (count > slots.current.rows.length) { + slots.current = appendChatRowSlots(slots.current, count - slots.current.rows.length); + } else if (count < slots.current.rows.length) { + slots.current = { ...slots.current, rows: slots.current.rows.slice(0, count) }; + } + } + const nextSignature = JSON.stringify(next); + if (nextSignature !== signature.current) { + if (signature.current !== "" && !stickToBottom.current) setNewMessages(true); + signature.current = nextSignature; + setState(next); + } + setError(null); setErrorStatus(null); setLoaded(true); + // only an answer laid out in full is skipped when it comes back unchanged: a read + // cancelled mid-way (a pane switch, the page hidden during a gap fill) is redone + lastAnswer.current = conversation; + }; + + useLayoutEffect(() => { shownPane.current = paneId; + stopRestore.current?.(); + restoring.current = readChatPosition(paneStorageId(machineId, paneId)) ?? null; history.current = undefined; setHistoryId(undefined); pagingAway.current = false; stickToBottom.current = true; signature.current = ""; setState(EMPTY_STATE); setNewMessages(false); setAway(false); setLoaded(false); setError(null); setErrorStatus(null); setPrompt(null); setAbandoned(null); setRestored(null); dropOlder(); lastAnswer.current = null; setSentOver(null); - }, [paneId]); + wasConnected.current = connected; + // Stale while revalidating: the last answer read for this pane draws before paint, and the + // mount read still asks the server; an unchanged answer (a 304) is this object and is skipped. + const cached = peekPaneConversation(paneId); + if (cached !== undefined && cached.source !== "scrollback" && loadedChatTimeline() !== null) { + history.current = cached.history_id; setHistoryId(cached.history_id); + showAnswerDetails(cached); + showAnswer(cached, conversationState(cached)); + } + }, [paneId, machineId]); + + const closeFind = useCallback(() => { + setFindOpen(false); setMatchKey(null); + const node = scroller.current; + if (node && node.scrollHeight - node.clientHeight - node.scrollTop <= 1) { + pagingAway.current = false; stickToBottom.current = true; setAway(false); setNewMessages(false); + } + }, []); + useEffect(() => { + if (!active || !visible || state.source !== "conversation") return; + const onKey = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.isComposing) return; + if (!scroller.current?.getClientRects().length || (event.target instanceof Element && event.target.closest('[role="dialog"]'))) return; + if (event.key === "Escape" && findOpen) { + event.preventDefault(); closeFind(); return; + } + // Mod is Cmd on a Mac (Ctrl+F there moves the caret in a text field) and Ctrl elsewhere + const mod = isMacPlatform() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; + if (event.shiftKey || event.altKey || !mod || event.key.toLowerCase() !== "f") return; + event.preventDefault(); + setFindOpen(true); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [active, visible, state.source, findOpen, closeFind]); + useLayoutEffect(() => { + if (wasFinding.current && !findOpen && active) findButton.current?.focus({ preventScroll: true }); + wasFinding.current = findOpen; + }, [findOpen, active]); useEffect(() => { if (seenSent.current === sentKey) return; @@ -707,13 +941,17 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, if (!visible) return; let cancelled = false; /** The turns between the held start and where the newest page now starts, a page at a time; null when they cannot be joined. */ - const turnsBetween = async (held: string, start: string): Promise => { + const turnsBetween = async (held: string | null, start: string): Promise => { const pages: ConversationTurn[][] = []; let before = start; try { for (let step = 0; step < 32 && before !== held; step++) { if (cancelled) return null; - const page = await fetchPaneConversation(paneId, { before, since: held }); + const page = await fetchPaneConversation(paneId, { before, ...(held === null ? {} : { since: held }) }); + if (page.cursor === held) { + pages.unshift(page.turns); + return pages.flat(); + } if (typeof page.cursor !== "string") return null; pages.unshift(page.turns); before = page.cursor; @@ -738,6 +976,11 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, conversation = await fetchPaneConversation(paneId); } if (cancelled || generation !== olderGeneration.current) return; + // the timeline's chunk is in hand before any turn is: the list mounts once, with the refs bound + if (conversation.source !== "scrollback" && loadedChatTimeline() === null) { + await loadChatTimeline(); + if (cancelled || generation !== olderGeneration.current) return; + } if (history.current !== conversation.history_id) { dropOlder(); generation = olderGeneration.current; history.current = conversation.history_id; setHistoryId(conversation.history_id); @@ -747,9 +990,10 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, if (conversation === lastAnswer.current) { setError(null); setErrorStatus(null); return; } // The newest page moved past the held start: the turns in between join the older // pages and the newest page is held from its new start, so no poll reads more than a page. - const held = heldFrom.current; + const previous = lastAnswer.current; + const held = heldFrom.current ?? (previous?.source !== "scrollback" && previous?.history_id === conversation.history_id ? previous?.cursor : undefined); let moved: ConversationTurn[] = []; - if (held !== null && conversation.source !== "scrollback" && typeof conversation.cursor === "string" && conversation.cursor !== held) { + if (held !== undefined && conversation.source !== "scrollback" && typeof conversation.cursor === "string" && conversation.cursor !== held) { const between = await turnsBetween(held, conversation.cursor); if (cancelled || generation !== olderGeneration.current) return; if (between === null) { @@ -758,12 +1002,13 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, } else { moved = between; heldFrom.current = conversation.cursor; } } - if (moved.length > 0) setOlder((turns) => [...turns, ...moved]); - if (heldFrom.current === null) setOlderCursor(conversation.source === "scrollback" ? undefined : conversation.cursor); - onMetadataRef.current?.(paneId, conversation.source === "scrollback" ? null : conversation.metadata ?? null); - setAbandoned(conversation.abandoned ?? null); + if (moved.length > 0) { + olderCount.current += moved.length; + setOlder((turns) => [...turns, ...moved]); + } + showAnswerDetails(conversation); let next: ChatState; - if (conversation.source !== "scrollback") next = { source: "conversation", conversationSource: conversation.source, turns: conversation.turns, messages: [], truncated: false, activity: conversation.activity }; + if (conversation.source !== "scrollback") next = conversationState(conversation); else if (agent !== null) next = { source: "scrollback", turns: [], messages: [], truncated: false }; else { // The reader has rejected this history. Hide it before the separate terminal @@ -777,16 +1022,7 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, if (cancelled || generation !== olderGeneration.current) return; next = { source: "scrollback", turns: [], messages: toTranscriptMessages(result.text).filter((message) => message.role !== "status"), truncated: result.truncated === true }; } - const nextSignature = JSON.stringify(next); - if (nextSignature !== signature.current) { - if (signature.current !== "" && !stickToBottom.current) setNewMessages(true); - signature.current = nextSignature; - setState(next); - } - setError(null); setErrorStatus(null); setLoaded(true); - // only an answer laid out in full is skipped when it comes back unchanged: a read - // cancelled mid-way (a pane switch, the page hidden during a gap fill) is redone - lastAnswer.current = conversation; + showAnswer(conversation, next); } catch (cause) { if (cancelled || generation !== olderGeneration.current) return; setLoaded(true); @@ -829,11 +1065,17 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, return () => onTerminalOutput(null); }, [state.source, visible, onTerminalOutput, conversationRefresh]); - // Catch writes made offline as soon as the existing socket is connected again. + // Catch writes made offline as soon as the existing socket is connected again. The first + // connection after the pane shows (a fresh attach) is no reconnect: while the mount read is + // still in flight, that read already answers for it, and a second full read would only repeat it. useEffect(() => { const before = seenConnected.current; seenConnected.current = connected; - if (connected && !before && visible) conversationRefresh.refresh(); + const reconnect = wasConnected.current; + if (connected) wasConnected.current = true; + if (!connected || before || !visible) return; + if (!reconnect && conversationRefresh.reading) return; + conversationRefresh.refresh(); }, [connected, visible, conversationRefresh]); const loadOlder = useCallback(async (): Promise => { @@ -856,7 +1098,13 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, if (page.source === "scrollback" || page.cursor === undefined) { setOlderCursor(undefined); setOlderState("idle"); return; } const first = heldFrom.current === null; heldFrom.current ??= before; - prepended.current = { top: node.scrollTop, height: node.scrollHeight }; + const top = node.getBoundingClientRect().top; + const anchor = [...node.querySelectorAll("[data-chat-row]")] + .filter((row) => row.getBoundingClientRect().bottom > top) + .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top)[0]; + if (anchor?.dataset.chatRow) prependAnchor.current = { key: anchor.dataset.chatRow, offset: anchor.getBoundingClientRect().top - top }; + slots.current = prependChatRowSlots(slots.current, page.turns.length); + olderCount.current += page.turns.length; setOlder((turns) => [...page.turns, ...turns]); setOlderCursor(page.cursor); setOlderState("idle"); @@ -875,15 +1123,14 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, }); }, [fetchPaneConversation, olderCursor, paneId, conversationRefresh]); - // Older turns went in above the reader: keep the same turns under their eyes. + // Reconcile the estimated prepend with the actual reader row, through the list's + // own scroll command. No scrollHeight delta or second virtualizer owns the position. useLayoutEffect(() => { - const node = scroller.current; - const anchor = prepended.current; - if (node === null || anchor === null) return; - prepended.current = null; - node.scrollTop = anchor.top + (node.scrollHeight - anchor.height); - // Keeping the reader's place is not a downward scroll: it must not undo an explicit page away. - lastScrollTop.current = node.scrollTop; + const anchor = prependAnchor.current; + if (anchor === null) return; + prependAnchor.current = null; + const index = slots.current.rows.findIndex((row) => row.key === anchor.key); + if (index >= 0) void timeline.current?.scrollToIndex({ index, viewPosition: 0, viewOffset: anchor.offset, animated: false }); }, [older]); // Prompts belong to the visible screen, not the native transcript: a Codex directory-trust @@ -933,7 +1180,10 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, // instead of scrolling there from the top. useLayoutEffect(() => { const node = scroller.current; - if (node !== null && stickToBottom.current) node.scrollTop = node.scrollHeight; + if (node !== null && stickToBottom.current) { + if (state.source === "conversation") timeline.current?.scrollToEnd({ animated: false }); + else node.scrollTop = node.scrollHeight; + } }, [state, prompt, agentStatus, connected, ended]); // A resized composer, a raised keyboard or a narrower window shrinks the view @@ -954,43 +1204,72 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, const next = `${entry.contentRect.width}|${style.fontSize}|${style.fontFamily}`; if (next !== lane) { lane = next; follow = true; } } - if (follow && stickToBottom.current) node.scrollTo({ top: node.scrollHeight, behavior: "instant" }); + if (follow && stickToBottom.current) { + if (timeline.current) timeline.current.scrollToEnd({ animated: false }); + else node.scrollTo({ top: node.scrollHeight, behavior: "instant" }); + } }); observer.observe(node); if (node.firstElementChild !== null) observer.observe(node.firstElementChild); return () => observer.disconnect(); - }, []); + }, [state.source, ChatTimeline]); // One of the app's faces arrived (font-display: swap; a unicode-range chunk on the first Korean // answer): the conversation rewraps with no width, size or family string changed, so the // observer above takes it for one that only grew taller. A reader at the end stays at the end. const faces = useFacesArrived(); useLayoutEffect(() => { const node = scroller.current; - if (node !== null && stickToBottom.current) node.scrollTo({ top: node.scrollHeight, behavior: "instant" }); + if (node !== null && stickToBottom.current) { + if (timeline.current) timeline.current.scrollToEnd({ animated: false }); + else node.scrollTo({ top: node.scrollHeight, behavior: "instant" }); + } }, [faces]); // a tap or a drag down the transcript puts a phone's keyboard away to read (lib/keyboard.ts) useEffect(() => { const node = scroller.current; return node === null ? undefined : dismissKeyboardOn(node); - }, []); + }, [state.source, ChatTimeline]); const onScroll = (): void => { const node = scroller.current; if (node === null) return; const distanceFromBottom = node.scrollHeight - node.clientHeight - node.scrollTop; const scrollingDown = node.scrollTop > lastScrollTop.current; + const scrollingUp = node.scrollTop < lastScrollTop.current; lastScrollTop.current = node.scrollTop; // DOM heights are rounded; only downward intent may use the endpoint rounding allowance. if (distanceFromBottom <= 0 || (scrollingDown && distanceFromBottom <= 1)) pagingAway.current = false; - stickToBottom.current = distanceFromBottom <= (pagingAway.current ? 0 : 48); + // A downward automatic scroll may arrive before the next measured layout. + // Only upward movement can turn an existing follow intent into reading intent. + if (!stickToBottom.current || scrollingUp || pagingAway.current) { + stickToBottom.current = distanceFromBottom <= (pagingAway.current ? 0 : 48); + } setAway(!stickToBottom.current); if (stickToBottom.current) setNewMessages(false); + if (loaded && restoring.current === null && state.source === "conversation") rememberPlace(node); if (node.scrollTop < LOAD_OLDER_PX && olderState === "idle") void loadOlder(); }; + // the turn at the top of the view and its offset there, or the end while the reader follows it + const rememberPlace = (node: HTMLDivElement): void => { + const top = node.getBoundingClientRect().top; + let first: { key: string; top: number } | null = null; + for (const row of node.querySelectorAll("[data-chat-row]")) { + const rect = row.getBoundingClientRect(); + if (rect.bottom > top && (first === null || rect.top < first.top)) first = { key: row.dataset.chatRow ?? "", top: rect.top }; + } + const turn = first === null ? undefined : timelineRows.find((row) => row.key === first.key)?.turn; + rememberChatPosition(paneStorageId(machineId, paneId), { + historyId: history.current ?? null, + turn: turn === undefined ? "" : chatTurnAnchor(turn), + offset: first === null ? 0 : first.top - top, + atEnd: stickToBottom.current || turn === undefined, + }); + }; const scrollToBottom = (): void => { const node = scroller.current; if (node === null) return; - node.scrollTo({ top: node.scrollHeight, behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }); + if (timeline.current) timeline.current.scrollToEnd({ animated: false }); + else node.scrollTo({ top: node.scrollHeight, behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }); pagingAway.current = false; stickToBottom.current = true; setNewMessages(false); setAway(false); }; useLayoutEffect(() => { @@ -1002,7 +1281,7 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, const style = getComputedStyle(prose); const lineHeight = parseFloat(style.lineHeight); const rowHeight = Number.isFinite(lineHeight) ? lineHeight : parseFloat(style.fontSize); - const distance = pageScrollDistance(node.clientHeight, rowHeight, settings.pageOverlapRows); + const distance = pageScrollDistance(node.clientHeight, rowHeight, PAGE_OVERLAP_ROWS); const before = node.scrollTop; node.scrollTo({ top: before + direction * distance, behavior: "instant" }); // A physical end may still report a positive subpixel gap, even with no movement. @@ -1014,7 +1293,12 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, // At the loaded top, PageUp still asks for the earlier page the scroll position cannot reach. if (node.scrollTop === before) { if (direction < 0) onScroll(); return; } lastScrollTop.current = before; - if (direction < 0) pagingAway.current = true; + if (direction < 0) { + pagingAway.current = true; + // Find, a minimap tick and a restored place scroll through the list, which drops a follow + // still waiting there; this native page scroll does not, so drop it here, or it lands after. + dropPendingListScroll(timeline.current); + } // Apply the intent before output or a deferred scroll event can restore following. onScroll(); }; @@ -1048,8 +1332,78 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, // the chat left the screen (another lens): nothing is known of it until it is read again useLayoutEffect(() => () => onRead?.(paneId, null), [onRead, paneId]); - return
    -
    + const rowSlots = slots.current.rows; + const timelineScope = JSON.stringify(slots.current.scope); + // another pane or PC: its pictures are not this one's + useEffect(() => setViewer(null), [timelineScope]); + const rowMemories = useMemo(() => new Map(), [timelineScope]); + const timelineRows = useMemo(() => shownTurns.map((turn, index) => { + const slot = rowSlots[index]; + if (!slot) throw new Error("A loaded chat turn has no row identity"); + return { key: slot.key, turn, beforeUser: turn.role === "assistant" && shownTurns[index + 1]?.role === "user" }; + }), [shownTurns, rowSlots]); + const searchRows = useMemo(() => findOpen ? timelineRows.map((row) => ({ key: row.key, text: chatSearchText(row.turn, rowMemories.get(row.key)?.outputs) })) : [], [findOpen, timelineRows, rowMemories, outputVersion]); + const showMatch = useCallback((match: ChatSearchMatch | null) => { + setMatchKey(match?.rowKey ?? null); + if (match === null) return; + const index = timelineRows.findIndex((row) => row.key === match.rowKey); + if (index < 0) return; + pagingAway.current = true; stickToBottom.current = false; setAway(true); + void timeline.current?.scrollToIndex({ index, animated: false, viewPosition: 0.35 }); + }, [timelineRows]); + // Back in a pane the reader left away from the end: the first answer read puts the turn they + // were on back where it sat, before paint. The list's index scroll starts from estimated heights, + // so the row is brought to its offset once measured, for a few frames at most; any scroll of the + // reader's own, or an earlier page loaded above (the prepend anchor), takes over at once. + useLayoutEffect(() => { + const saved = restoring.current; + if (saved === null || !loaded || error !== null || state.source !== "conversation") return; + restoring.current = null; + const index = chatRestoreIndex(saved, history.current ?? null, timelineRows.map((row) => chatTurnAnchor(row.turn))); + const list = timeline.current, node = scroller.current; + if (index === null || list === null || node === null) return; + const key = timelineRows[index]!.key; + pagingAway.current = true; stickToBottom.current = false; setAway(true); + void list.scrollToIndex({ index, viewPosition: 0, viewOffset: saved.offset, animated: false }); + const olderAt = olderCount.current; + let frame = 0, frames = 0, stable = 0; + const stop = (): void => { + cancelAnimationFrame(frame); + for (const type of ["wheel", "touchmove", "pointerdown"]) node.removeEventListener(type, stop); + document.removeEventListener("keydown", stop, true); + if (stopRestore.current === stop) stopRestore.current = null; + }; + const settle = (): void => { + if (++frames > 30 || timeline.current !== list || olderCount.current !== olderAt || prependAnchor.current !== null) { stop(); return; } + const row = node.querySelector(`[data-chat-row="${CSS.escape(key)}"]`); + if (row !== null) { + const delta = row.getBoundingClientRect().top - node.getBoundingClientRect().top - saved.offset; + if (Math.abs(delta) > 1) { stable = 0; void list.scrollToOffset({ offset: node.scrollTop + delta, animated: false }); } + else if (++stable >= 2) { stop(); return; } + } + frame = requestAnimationFrame(settle); + }; + for (const type of ["wheel", "touchmove", "pointerdown"]) node.addEventListener(type, stop, { passive: true }); + document.addEventListener("keydown", stop, true); + stopRestore.current = stop; + frame = requestAnimationFrame(settle); + }, [loaded, error, state.source, timelineRows]); + useEffect(() => () => stopRestore.current?.(), []); + const minimap = useMemo(() => minimapTurns(shownTurns), [shownTurns]); + const rowKeys = useMemo(() => timelineRows.map((row) => row.key), [timelineRows]); + // a tick takes the reader to its turn the way Find does: reading there, follow left off + const jumpToTurn = useCallback((index: number) => { + pagingAway.current = true; stickToBottom.current = false; setAway(true); + void timeline.current?.scrollToIndex({ index, animated: false, viewPosition: 0 }); + }, []); + const renderTurn = useCallback((row: { key: string; turn: ConversationTurn }, index: number) => { + const { turn } = row; + const live = isLiveWorkTurn(turn, index === shownTurns.length - 1, workStatus, finishedBeforeSend); + return

    {t("This message can't be shown here. The terminal has it.")}

    }> + +
    ; + }, [shownTurns.length, workStatus, finishedBeforeSend, t, paneId, agentStatus, settings.showThinking, skillMatcher, matchKey]); + const header = <> {/* the conversation below is not all the file holds: a /tree left these behind, and pi moved its leaf without writing anything, so nothing here could say they were ever there. First in the transcript, because paging back would otherwise drop them under their own heading */} @@ -1071,17 +1425,8 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, )} {state.source === "conversation" && olderCursor === null && older.length > 0 &&

    {t("beginning of conversation")}

    } - {state.source === "conversation" - ? shownTurns.map((turn, index) => { - const last = index === shownTurns.length - 1; - const live = isLiveWorkTurn(turn, last, workStatus, finishedBeforeSend); - return

    {t("This message can't be shown here. The terminal has it.")}

    }> - -
    ; - }) - : agent !== null - ? - : state.messages.map((message, index) => )} + ; + const footer = <> {!ended && !connected &&

    {t("Reconnecting…")}

    } {error !== null &&

    {errorStatus === 401 ? t("locked — the token gate is asking again") : error}

    } {!loaded && error === null &&

    {t("Loading conversation…")}

    } @@ -1092,10 +1437,50 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, {state.activity !== undefined && connected && !ended && activity !== "idle" && agentStatus !== "blocked" && prompt === null && (activity === "thinking" || !workBlockLive) &&

    } {loaded && empty && error === null && prompt === null && (state.activity === undefined || activity === "idle") && !(greeted && blank) &&

    {t("No conversation yet — say something below")}

    } {ended &&

    {t("terminal ended")}

    } + ; + const jump = newMessages ? + : away && ; + return +
    + {state.source === "conversation" && findOpen && } +
    + {state.source === "conversation" ? ChatTimeline === null + // its chunk is still loading: no turn is read until it arrives, so this is the loading state + ?
    +
    +
    {header}
    +
    {footer}
    +
    +
    + : :
    +
    + {agent !== null ? + : state.messages.map((message, index) => )} + {footer} +
    +
    } + {state.source === "conversation" && } + {jump} + {state.source === "conversation" && !findOpen && } +
    - {newMessages ? - : away && } -
    {/* The prompt card is not part of the transcript: it is drawn on the composer's column, over the input card, where PaneTerminal keeps its place. The prompt itself (its poll, the answer, the re-read) stays here. */} @@ -1108,5 +1493,6 @@ export const ChatView = memo(function ChatView({ paneId, pageScroll, refreshKey, if (prompt.steps) setPromptPollKey((key) => key + 1); onPendingAnswerDone?.(paneId, prompt.id); }} />, promptDock)} - ; + {viewer !== null && } + ; }); diff --git a/src/components/ChunkFailureNotice.test.tsx b/src/components/ChunkFailureNotice.test.tsx new file mode 100644 index 000000000..f352e1ead --- /dev/null +++ b/src/components/ChunkFailureNotice.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { lazyChunk } from "../lib/lazyChunk.ts"; +import { SettingsProvider } from "../lib/settings.ts"; +import { ChunkFailureNotice } from "./UpdateControls.tsx"; + +describe("the reader tab's reload line", () => { + it("offers a reload once a part of the app failed to load, without the update model", async () => { + // FileReader mounts it where App would mount the update line: KaTeX that cannot load leaves math as source + await expect(lazyChunk(() => Promise.reject(new TypeError("Failed to fetch dynamically imported module: /assets/katex-old.js")))()).rejects.toThrow("Failed to fetch"); + const languages = Object.getOwnPropertyDescriptor(navigator, "languages"); + Object.defineProperty(navigator, "languages", { configurable: true, value: ["en"] }); + try { + const html = renderToStaticMarkup(createElement(SettingsProvider, { children: createElement(ChunkFailureNotice) })); + expect(html).toContain("Part of the app could not load."); + expect(html).toContain("Reload app"); + } finally { + if (languages) Object.defineProperty(navigator, "languages", languages); + else Reflect.deleteProperty(navigator, "languages"); + } + }); +}); diff --git a/src/components/CommandPalette.css b/src/components/CommandPalette.css index 50993e661..ce4360b47 100644 --- a/src/components/CommandPalette.css +++ b/src/components/CommandPalette.css @@ -170,6 +170,12 @@ min-height: var(--row-h); } +/* a folder row (a path typed in the search): its glyph quieter than the name, the path under a jump */ +.palette-folder > svg { + flex: 0 0 auto; + color: var(--text-dim); +} + .palette-mark { display: inline-flex; flex: 0 0 auto; diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 493224c14..2751b49d9 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -1,21 +1,23 @@ import { MachineContext, useMachineApi, useMachineId } from "../lib/machineContext.tsx"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentType, type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; -import { Bell, Columns2, FolderOpen, LoaderCircle, LockKeyhole, Maximize2, MessageSquarePlus, Minimize2, Monitor, PanelLeft, Plus, Puzzle, RefreshCw, Rows2, Settings, SunMoon, SwitchCamera, X } from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentType, type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; +import { Bell, Columns2, CornerLeftUp, Folder, FolderOpen, FolderSearch, History, LoaderCircle, LockKeyhole, Maximize2, MessageSquarePlus, Minimize2, Monitor, PanelLeft, Pin, Plus, Puzzle, RefreshCw, Rows2, Settings, SunMoon, SwitchCamera, X } from "lucide-react"; import "./CommandPalette.css"; -import type { HerdrPane, PluginActions, SessionSnapshot, TabInfo } from "../../shared/protocol.ts"; +import type { DirectoryListing, HerdrPane, PluginActions, SessionSnapshot, TabInfo } from "../../shared/protocol.ts"; import type { Machine } from "../../shared/machines.ts"; import { ApiError } from "../lib/api.ts"; import { paneStatus, STATUS_WORD } from "../lib/status.ts"; import { zoomMode } from "../lib/layoutMap.ts"; import type { AppActions, PaneView } from "../lib/actions.ts"; import { FILTER_KEYS, offeredPluginActions, parseQuery, STATUS_FILTERS, statusCounts, type OfferedPluginAction, type PaletteStatusFilter } from "../lib/paletteSearch.ts"; +import { actionAliases, rankByTitle, requestSettingsPage, settingsPageEntries } from "../lib/paletteSettings.ts"; import { Ownership, runPluginActionToEnd } from "../lib/pluginRun.ts"; import { shortcutDisplayKeys, formatKeys, type ShortcutId } from "../lib/shortcuts.ts"; import { useSettings } from "../lib/settings.ts"; import { AgentMark } from "./AgentMark.tsx"; import { displayPaneTitle, StatusBadge } from "./Sidebar.tsx"; +import { PAGES } from "./SettingsDialog.tsx"; import { folderName, placeLine } from "../lib/paneName.ts"; import { agentTabName } from "../lib/sidebarAgents.ts"; import { tabLabel } from "../lib/tabName.ts"; @@ -26,13 +28,20 @@ import { usePhoneSheetBack } from "../lib/sheetHistory.ts"; import { useMediaQuery } from "../lib/useMediaQuery.ts"; import { useWorktreeBranches } from "../lib/useWorktreeBranches.ts"; import { worktreeLabel } from "../lib/worktreeName.ts"; +import { browseEntries, folderQuery, folderQueryFor, homeRelative, matchFolders, pinnedFolders, requestFilesFolder } from "../lib/paletteFolders.ts"; +import { currentSidebarPins, subscribeSidebarPins } from "../lib/sidebarPins.ts"; +import { recentDirectories } from "../lib/recent-directories.ts"; import { availableTarget, groupMachinePanes, loadRecentTargets, rankMachinePanes, recentTargets, rememberTarget, targetKey, RECENT_KEY, type MachineBranches, type MachinePane, type RecentPane } from "../lib/machinePalette.ts"; import { worktreeWorkspaceSignature, type WorkspaceBranch } from "../lib/worktreeBranches.ts"; const RECENT_SHOWN = 3; +/** pinned and recent folders offered as jumps, each */ +const FOLDERS_SHOWN = 5; interface CommandPaletteProps { open: boolean; + /** the search text it opens with; blank from the shortcut */ + initialQuery?: string; onClose: () => void; machines: readonly Machine[]; onSelect(machineId: string, paneId: string): void; @@ -48,13 +57,21 @@ interface PaletteAction { label: string; icon: ComponentType; shortcut?: ShortcutId; + /** the palette stays open after it: it changes the palette's own list */ + keepOpen?: boolean; run: () => void; } interface PaneRow extends MachinePane { kind: "pane" } interface ActionRow { kind: "action"; action: PaletteAction } interface PluginRow { kind: "plugin"; offered: OfferedPluginAction } -type PaletteRow = PaneRow | ActionRow | PluginRow; +/** a folder of the selected PC: picking it lists it in the palette */ +interface FolderRow { kind: "folder"; id: string; label: string; path: string; detail: string | null; icon: ComponentType } +type PaletteRow = PaneRow | ActionRow | PluginRow | FolderRow; + +/** what went wrong listing a folder, said in the empty list */ +type BrowseError = "invalid" | "failed"; +interface FolderBrowse { directory: string; listing: DirectoryListing | null; error: BrowseError | null } interface PaletteSectionView { id: string; @@ -86,7 +103,15 @@ function pluginActionKey({ plugin, action }: OfferedPluginAction): string { } function rowKey(sectionId: string, row: PaletteRow): string { - return `${sectionId}:${row.kind === "pane" ? targetKey(row) : row.kind === "action" ? row.action.id : pluginActionKey(row.offered)}`; + return `${sectionId}:${row.kind === "pane" ? targetKey(row) : row.kind === "action" ? row.action.id : row.kind === "folder" ? row.id : pluginActionKey(row.offered)}`; +} + +function lastSegment(path: string): string { + return path.replace(/[\\/]+$/u, "").split(/[\\/]/u).pop() || path; +} + +function childPath(parent: string, name: string): string { + return parent.endsWith("/") ? `${parent}${name}` : `${parent}/${name}`; } function storeRecent(recent: RecentPane[]): RecentPane[] { @@ -121,7 +146,7 @@ function ShortcutHint({ shortcutId }: { shortcutId?: ShortcutId }) { return {keys.map((key) => {key})}; } -export function CommandPalette({ open, onClose, machines, onSelect, snapshot, online, selectedPaneId, view, actions }: CommandPaletteProps) { +export function CommandPalette({ open, initialQuery = "", onClose, machines, onSelect, snapshot, online, selectedPaneId, view, actions }: CommandPaletteProps) { const t = useT(); const machineId = useMachineId(); const api = useMachineApi(); @@ -138,6 +163,12 @@ export function CommandPalette({ open, onClose, machines, onSelect, snapshot, on owner.current ??= new Ownership(); const loadRecent = () => loadRecentTargets((key) => window.localStorage.getItem(key), [machineId, ...machines.map((machine) => machine.id).filter((id) => id !== machineId)]); const [recentPaneIds, setRecentPaneIds] = useState(loadRecent); + // folder browsing (a query that starts as a path does): the folder listed, the PC's home once + // one listing told it, the PC's recent folders and the folders of the pinned agents + const [browse, setBrowse] = useState({ directory: "", listing: null, error: null }); + const [home, setHome] = useState(null); + const [recentFolders, setRecentFolders] = useState([]); + const pins = useSyncExternalStore(subscribeSidebarPins, currentSidebarPins); const inputRef = useRef(null); // a pane picked here is where the keyboard goes: giving it back to the opener (a sibling pane // still mounted in the split) would select that pane again (PaneCanvas onFocusCapture) @@ -186,13 +217,42 @@ export function CommandPalette({ open, onClose, machines, onSelect, snapshot, on // task, where a quick Tab focuses a row at an index the full list then gives to another row (#608). useLayoutEffect(() => { if (!open) return; - setQuery(""); + setQuery(initialQuery); setFilter("all"); setSelection(NO_SELECTION); focusedRow.current = null; + requestSettingsPage(null); setRecentPaneIds(loadRecent()); + setRecentFolders(recentDirectories.read(machineId)); }, [open]); + // The folder a path-like query names, read from the selected PC as the query crosses a `/`: the + // last segment only filters what is listed. A dot starting it asks for the hidden folders too. + const folder = open ? folderQuery(query) : null; + const folderDirectory = folder?.directory ?? null; + const folderHidden = folder?.filter.startsWith(".") ?? false; + useEffect(() => { + if (folderDirectory === null || !online) return; + let alive = true; + setBrowse((current) => current.directory === folderDirectory ? { ...current, error: null } : { directory: folderDirectory, listing: null, error: null }); + api.fetchDirectories(folderDirectory, folderHidden).then( + (listing) => { + if (!alive) return; + setHome(listing.home); + setBrowse({ directory: folderDirectory, listing, error: null }); + }, + (reason: unknown) => { + if (alive) setBrowse({ directory: folderDirectory, listing: null, error: reason instanceof ApiError && reason.code === "invalid_cwd" ? "invalid" : "failed" }); + }, + ); + return () => { alive = false; }; + }, [folderDirectory, folderHidden, online, api]); + const browseTo = useCallback((path: string): void => { + setQuery(folderQueryFor(path, home)); + setSelection(NO_SELECTION); + inputRef.current?.focus(); + }, [home]); + // The PC's plugin actions, read on each opening. A herdr with no plugins, an older bridge // without the route and an unreachable herdr all leave the group out. A layout effect: the // rows of the last opening are drawn again at once, and a tap on one must find this opening's @@ -230,18 +290,21 @@ export function CommandPalette({ open, onClose, machines, onSelect, snapshot, on // then sets the tab's flag, so a toggle on a pane other than the one shown alone would unzoom the tab const zoom = tabLayout !== undefined && selectedPaneId !== null ? zoomMode(tabLayout, selectedPaneId) : null; const { splitPane, zoomPane } = actions; + // browsing starts in the selected pane's folder, home without one + const selectedPane = snapshot?.panes.find((pane) => pane.pane_id === selectedPaneId); + const browseStart = selectedPane && panePath(selectedPane) ? panePath(selectedPane) : null; const paletteActions = useMemo(() => [ { id: "new", label: t("New workspace"), icon: MessageSquarePlus, shortcut: "new-session", run: actions.openNewSession }, // in the selected pane's workspace: nothing to add a tab to without one - ...(selectedPaneId !== null ? [{ id: "new-tab", label: t("New tab"), icon: Plus, run: () => actions.openNewTab() }] : []), + ...(selectedPaneId !== null ? [{ id: "new-tab", label: t("New tab"), icon: Plus, shortcut: "new-tab", run: () => actions.openNewTab() }] satisfies PaletteAction[] : []), // herdr's prefix+v and prefix+-: the new pane is opened here only when asked, as herdr's --focus ...(splitPane ? [ - { id: "split-right", label: t("Split pane right"), icon: Columns2, run: () => splitPane("right") }, - { id: "split-down", label: t("Split pane down"), icon: Rows2, run: () => splitPane("down") }, + { id: "split-right", label: t("Split pane right"), icon: Columns2, shortcut: "split-right", run: () => splitPane("right") }, + { id: "split-down", label: t("Split pane down"), icon: Rows2, shortcut: "split-down", run: () => splitPane("down") }, { id: "split-right-open", label: t("Split pane right and open it"), icon: Columns2, run: () => splitPane("right", true) }, { id: "split-down-open", label: t("Split pane down and open it"), icon: Rows2, run: () => splitPane("down", true) }, - ] : []), + ] satisfies PaletteAction[] : []), ...(zoomPane && zoom && tabPanes > 1 ? [{ id: "zoom", label: t(zoom === "off" ? "Unzoom pane" : "Zoom pane"), icon: zoom === "off" ? Minimize2 : Maximize2, run: () => zoomPane(zoom) }] : []), { id: "view", label: t(view === "chat" ? "Switch to terminal" : "Switch to chat"), icon: SwitchCamera, shortcut: "toggle-view", run: actions.toggleView }, { id: "sidebar", label: t("Toggle sidebar"), icon: PanelLeft, shortcut: "toggle-sidebar", run: actions.toggleSidebar }, @@ -251,8 +314,16 @@ export function CommandPalette({ open, onClose, machines, onSelect, snapshot, on ...(actions.enableNotifications ? [{ id: "notifications", label: t("Enable notifications"), icon: Bell, run: actions.enableNotifications }] : []), ...(actions.lock ? [{ id: "lock", label: t("Sign out"), icon: LockKeyhole, run: actions.lock }] : []), ...(actions.openFiles ? [{ id: "files", label: t("Browse files"), icon: FolderOpen, run: actions.openFiles }] : []), + ...(online ? [{ id: "browse-folders", label: t("Browse folders"), icon: FolderSearch, keepOpen: true, run: () => browseTo(browseStart ?? home ?? "~") }] : []), { id: "refresh", label: t("Refresh"), icon: RefreshCw, run: actions.refresh }, - ], [actions, splitPane, zoomPane, tabPanes, zoom, view, t, selectedPaneId]); + ], [actions, splitPane, zoomPane, tabPanes, zoom, view, t, selectedPaneId, online, browseTo, browseStart, home]); + + // Settings pages are found by a query only, and open on the page itself: the dialog reads the request as it mounts + const settingsActions = useMemo(() => { + const icons = new Map(PAGES.map(({ id, icon }) => [id, icon])); + return settingsPageEntries(t).map((entry) => ({ action: { id: `settings-${entry.id}`, label: entry.label, icon: icons.get(entry.id) ?? Settings, run: () => { requestSettingsPage(entry.id); actions.openSettings(); } } satisfies PaletteAction, aliases: entry.aliases })); + }, [actions, t]); + const aliasesByAction = useMemo(() => actionAliases(t), [t]); const allPanes = useMemo(() => machines.flatMap((machine) => machine.state === "connected" ? machine.snapshot?.panes ?? [] : []), [machines]); const multiMachine = machines.length > 1; @@ -265,6 +336,29 @@ export function CommandPalette({ open, onClose, machines, onSelect, snapshot, on // the panes went to last lead an unsearched, unfiltered list const sections = useMemo(() => { const list: PaletteSectionView[] = []; + // a path lists the selected PC's folders (T3 Code's browse mode): where it is, with Open in + // Files and the way up, then the pinned and recent folders it leads to, then its folders + if (folder !== null) { + const listing = browse.directory === folder.directory ? browse.listing : null; + const machineName = multiMachine ? selectedMachineName : undefined; + const jump = (path: string, prefix: string, icon: ComponentType): FolderRow => ({ kind: "folder", id: `${prefix}:${path}`, label: lastSegment(path), path, detail: home === null ? path : homeRelative(path, home), icon }); + if (listing && folder.filter === "") { + const { openFiles } = actions; + const here: PaletteRow[] = [ + ...(openFiles ? [{ kind: "action" as const, action: { id: "open-folder", label: t("Open in Files"), icon: FolderOpen, run: () => { requestFilesFolder(listing.path); openFiles(); } } }] : []), + ...(listing.parent !== null ? [{ kind: "folder" as const, id: "up", label: "..", path: listing.parent, detail: null, icon: CornerLeftUp }] : []), + ]; + if (here.length > 0) list.push({ id: "folder-here", heading: folderQueryFor(listing.path, listing.home), machineName, branch: null, placeNamesWorkspace: false, rows: here }); + } + const pinned = pinnedFolders(pins, machineId, snapshot?.panes ?? []); + const pinnedRows = matchFolders(pinned, query, home, FOLDERS_SHOWN).map((path) => jump(path, "pin", Pin)); + if (pinnedRows.length > 0) list.push({ id: "pinned-folders", heading: t("Pinned folders"), machineName, branch: null, placeNamesWorkspace: false, rows: pinnedRows }); + const recentRows = matchFolders(recentFolders.filter((path) => !pinned.includes(path)), query, home, FOLDERS_SHOWN).map((path) => jump(path, "recent", History)); + if (recentRows.length > 0) list.push({ id: "recent-folders", heading: t("Recent folders"), machineName, branch: null, placeNamesWorkspace: false, rows: recentRows }); + const names = listing ? browseEntries(listing.directories, folder.filter) : []; + if (listing && names.length > 0) list.push({ id: "folder-list", heading: t("Folders"), machineName, branch: null, placeNamesWorkspace: false, rows: names.map((name) => ({ kind: "folder", id: `dir:${name}`, label: name, path: childPath(listing.path, name), detail: null, icon: Folder })) }); + return list; + } if (!actionsOnly) { const panes = rankMachinePanes(text, machines, filter, branches, t); if (text === "" && filter === "all") { @@ -277,14 +371,16 @@ export function CommandPalette({ open, onClose, machines, onSelect, snapshot, on } } if (actionsOnly || filter === "all") { - const needle = text.toLocaleLowerCase(); - const visible = needle ? paletteActions.filter((action) => action.label.toLocaleLowerCase().includes(needle)) : paletteActions; + // a name found only through an alias comes after the titles that hold the query + const visible = rankByTitle(text, paletteActions.map((action) => ({ item: action, title: action.label, aliases: aliasesByAction[action.id] }))); if (visible.length > 0) list.push({ id: "actions", heading: t("Actions"), machineName: multiMachine ? selectedMachineName : undefined, branch: null, placeNamesWorkspace: false, rows: visible.map((action) => ({ kind: "action", action })) }); + const pages = text === "" ? [] : rankByTitle(text, settingsActions.map(({ action, aliases }) => ({ item: action, title: action.label, aliases }))); + if (pages.length > 0) list.push({ id: "settings-pages", heading: t("Settings"), machineName: multiMachine ? selectedMachineName : undefined, branch: null, placeNamesWorkspace: false, rows: pages.map((action) => ({ kind: "action", action })) }); const offered = offeredPluginActions(plugins, selectedPaneId !== null, text); if (offered.length > 0) list.push({ id: "plugins", heading: t("Plugin actions"), machineName: multiMachine ? selectedMachineName : undefined, branch: null, placeNamesWorkspace: false, rows: offered.map((item) => ({ kind: "plugin", offered: item })) }); } return list; - }, [actionsOnly, text, filter, machines, multiMachine, selectedMachineName, machineId, branches, recentPaneIds, selectedPaneId, paletteActions, plugins, t]); + }, [actionsOnly, text, filter, machines, multiMachine, selectedMachineName, machineId, branches, recentPaneIds, selectedPaneId, paletteActions, aliasesByAction, settingsActions, plugins, t, folder?.directory, folder?.filter, query, browse, home, pins, recentFolders, snapshot, actions]); const rows = useMemo(() => sections.flatMap((section) => section.rows), [sections]); const rowKeys = useMemo(() => sections.flatMap((section) => section.rows.map((row) => rowKey(section.id, row))), [sections]); @@ -365,7 +461,7 @@ export function CommandPalette({ open, onClose, machines, onSelect, snapshot, on }; const runAction = (action: PaletteAction): void => { action.run(); - onClose(); + if (!action.keepOpen) onClose(); }; const runPluginAction = async ({ plugin, action }: OfferedPluginAction): Promise => { if (running !== null) return; @@ -401,6 +497,7 @@ export function CommandPalette({ open, onClose, machines, onSelect, snapshot, on if (!row) return; if (row.kind === "pane") runPane(row); else if (row.kind === "action") runAction(row.action); + else if (row.kind === "folder") browseTo(row.path); else void runPluginAction(row.offered); }; const adjacentSectionStart = (index: number, direction: -1 | 1): number => { @@ -496,8 +593,14 @@ export function CommandPalette({ open, onClose, machines, onSelect, snapshot, on const footerPlace = activeRow?.kind === "pane" ? [multiMachine ? activeRow.machine.name : null, activeWorkspace?.label ?? t("Unknown workspace"), activeTab?.tab ? tabLabel(activeTab.tab, t, activeTab.place) : null].filter(Boolean).join(" › ") : activeRow?.kind === "action" ? activeRow.action.label + : activeRow?.kind === "folder" ? activeRow.label : activeRow?.offered.action.title ?? ""; - const footerPath = activeRow?.kind === "pane" ? panePath(activeRow.pane) : ""; + const footerPath = activeRow?.kind === "pane" ? panePath(activeRow.pane) : activeRow?.kind === "folder" ? activeRow.path : ""; + const browseListing = folder !== null && browse.directory === folder.directory ? browse : null; + const emptyText = folder === null ? t("No matching panes or actions") + : browseListing?.error === "invalid" ? t("This folder cannot be opened.") + : browseListing?.error === "failed" || !online ? t("Folders could not be loaded.") + : browseListing?.listing ? t("No matching folders") : t("Loading…"); const onSurfaceFocus = (event: ReactFocusEvent): void => { focusedRow.current = (event.target as HTMLElement).closest("[role=\"option\"]"); @@ -512,7 +615,7 @@ export function CommandPalette({ open, onClose, machines, onSelect, snapshot, on { setQuery(event.target.value); setSelection(NO_SELECTION); }} />
    - {!actionsOnly && ( + {!actionsOnly && folder === null && (
    {STATUS_FILTERS.map((status) => ( ); } + if (row.kind === "folder") { + const Icon = row.icon; + return ( + + ); + } if (row.kind === "action") { const Icon = row.action.icon; return ; @@ -568,7 +680,7 @@ export function CommandPalette({ open, onClose, machines, onSelect, snapshot, on })}
    ))} - {itemCount === 0 &&

    {t("No matching panes or actions")}

    } + {itemCount === 0 &&

    {emptyText}

    }
    diff --git a/src/components/Composer.css b/src/components/Composer.css index e63499822..fdab1fc35 100644 --- a/src/components/Composer.css +++ b/src/components/Composer.css @@ -142,9 +142,11 @@ } } +/* the notices (ComposerNotices.css) take the card's column too */ .composer-quick, .composer-surface, -.composer-note { +.composer-note, +.composer-notices { width: min(100%, var(--chat-w)); margin-inline: auto; } @@ -159,11 +161,9 @@ (.visually-hidden: the mark stays, and the state is told by Stop, the live row and the prompt card). What does not fit gives way in this order: the task chip's words (BackgroundTasks.css, by the card's width); then the model label, as composerModelDraw says of the measured row - (Composer.tsx marks data-model): while Queue is showing the mark, the name and the level step - out whole and their pill with them, without it the level alone does, each read and not drawn, - so no word is drawn in part; then, with no Queue, the model's name and the opened context - text. Nothing overlaps its neighbour, and the context ring at the end is never the part that - is cut. */ + (Composer.tsx marks data-model): the level steps out whole, read and not drawn, so no word is + drawn in part; then the model's name and the opened context text. Nothing overlaps its + neighbour, and the context ring at the end is never the part that is cut. */ .composer-status { grid-area: status; display: flex; @@ -192,6 +192,26 @@ row-gap: 0; } +/* A narrow card (data-compact: the card's own width, Composer.tsx) with a sentence to say: add, + the icons and the round button at touch size leave the status content too little of the row + for a word, so it takes a row of its own over them, the card's width, where the sentence is + read whole beside the model pill or under it, from where the message's text starts. Add and + the round button keep their row. The card grows by that row while the sentence shows. + data-hint is the sentence's presence, not a measurement, so this layout never feeds back into + the fit that measures the row */ +.composer-surface[data-compact]:has(> .composer-status[data-hint]) { + grid-template-areas: + "attachments attachments attachments" + "text text text" + "status status status" + "left . right"; +} + +.composer-surface[data-compact]:has(> .composer-status[data-hint]) > .composer-status { + justify-content: flex-start; + padding: var(--space-1) var(--space-4) 0; +} + /* everything but the sentence. No box of its own at rest: its items are the status line's. Beside a sentence it is one row that does not wrap, so the sentence is the only thing that takes a line: a label that does not fit is clipped there as it is at rest, which is what the composer @@ -369,6 +389,28 @@ cursor: default; } +/* Compact context: beside the opened number, desktop only (the mobile ring is inert) */ +.composer-compact { + display: inline-flex; + flex: none; + align-items: center; + padding: 0; + border: 0; + background: none; + color: var(--text-dim); + cursor: pointer; +} + +.composer-compact svg { + width: 14px; + height: 14px; +} + +.composer-compact:hover, +.composer-compact:focus-visible { + color: var(--text); +} + .composer-usage { flex: none; color: var(--text-dim); @@ -411,12 +453,14 @@ height: var(--chip-h); } -/* the message is the card's first row at its full width; under it one row: add and the task - chip | the status content | the one round Send/Stop button. The attachment strip is its own +/* the message is the card's first row at its full width; under it one row: add, the mic and the + task chip | the status content | the one round Send/Stop button. The attachment strip is its own row above both. The three cells of the last row stretch and centre what they hold, so the - controls and the model share one centre line */ + controls and the model share one centre line. The card stands over the strip tucked under it (below), so + it is raised; its popovers go with it and still rise over the transcript, which comes first */ .composer-surface { position: relative; + z-index: 1; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: @@ -515,7 +559,7 @@ min-width: 0; min-height: calc(1lh + var(--space-5)); max-height: 10lh; - padding: var(--space-4) var(--space-5) var(--space-1); + padding: var(--space-4) var(--space-4) var(--space-1); resize: none; overflow-x: hidden; overflow-y: auto; @@ -678,6 +722,18 @@ cursor: default; } +.composer-send[aria-busy="true"] { + opacity: 1; +} + +.composer-send-progress { + animation: composer-send-spin var(--dur-spin) steps(8) infinite; +} + +@keyframes composer-send-spin { + to { transform: rotate(360deg); } +} + @media (hover: hover) { .composer-send:hover:not(:disabled) { --composer-action-fill: var(--primary-hover); @@ -692,6 +748,66 @@ } } +/* The strip tucked under the card, as t3code's composer context strip: the card's own surface, + inset on each side by the card's radius (never less than --space-5, for a sharp palette), so it + stays under the card's straight bottom edge; its top --space-4 under the card, where the card's + hairline and shadow part the two (the seam), and only its bottom corners rounded. It names the + pane's working folder (the whole path in its title) at its start and the branch at its end, + each a glyph and a name in --text-dim at --fs-xs, and nothing in it is pressed. Composer.tsx + draws it only with a folder or a branch to name; a name that does not fit is cut short */ +.composer-strip { + --composer-strip-inset: max(var(--radius-2xl), var(--space-5)); + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + width: calc(min(100%, var(--chat-w)) - 2 * var(--composer-strip-inset)); + margin: calc(-1 * var(--space-4)) auto 0; + padding: var(--space-5) var(--space-3) var(--space-1); + border: var(--hairline) solid var(--border); + border-top: 0; + border-radius: 0 0 var(--radius-xl) var(--radius-xl); + background: var(--bg-elevated); + color: var(--text-dim); + font-size: var(--fs-xs); +} + +.composer-strip-item { + display: inline-flex; + flex: 0 1 auto; + align-items: center; + gap: var(--space-1); + min-width: 0; + height: var(--chip-h); +} + +/* a linked worktree says what it is: the folder's name alone reads like any checkout's */ +.composer-strip-kind { + flex: none; + padding: 0 var(--space-1); + border: var(--hairline) solid var(--border); + border-radius: var(--radius-sm); +} + +.composer-strip-branch { + margin-inline-start: auto; +} + +/* the glyph is the text's size, as the task chip's is */ +.composer-strip-item svg { + flex: none; + width: var(--fs-xs); + height: var(--fs-xs); +} + +.composer-strip-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .composer-menu { position: absolute; right: 0; @@ -747,7 +863,7 @@ grid-area: attachments; display: flex; gap: var(--space-2); - padding: var(--space-3) var(--space-5) 0; + padding: var(--space-3) var(--space-4) 0; overflow-x: auto; } @@ -784,7 +900,8 @@ white-space: nowrap; } -.composer-attachment.is-error { +.composer-attachment.is-error, +.composer-attachment.is-no-room { border-color: var(--status-blocked); } @@ -864,11 +981,11 @@ } .composer-attachments { - padding-inline: var(--space-4); + padding-inline: var(--space-3); } .composer-text { - padding: var(--space-3) var(--space-4) 0; + padding: var(--space-3) var(--space-3) 0; } .composer-controls-left { @@ -893,6 +1010,10 @@ padding: var(--space-1) var(--space-2) var(--space-2) var(--space-1); } + .composer-surface[data-compact]:has(> .composer-status[data-hint]) > .composer-status { + padding: var(--space-1) var(--space-3) 0; + } + /* A phone fits neither half of a pi command's row, and pi writes explanations twice as long as the line is wide, so the description takes a line to itself and wraps onto more. */ .composer-menu .menu-item { @@ -933,6 +1054,10 @@ } @media (prefers-reduced-motion: reduce) { + .composer-send-progress { + animation: none; + } + .composer-surface, .composer-action::before, .composer-resize::before { @@ -1305,22 +1430,22 @@ /* A phone's input card floats over the transcript's end, as an iPhone's input bar does: the composer rises --composer-over into the transcript, whose last lines fade into the chat's surface and blur behind the glass card. Only while the card follows the transcript directly - (no held message, pending row or prompt card between them) and no greeting stands over it; + (no held message, pending row, prompt card or notice between them) and no greeting stands over it; ChatView.css keeps the last line and its jump button clear of the card. The transcript's box still shrinks as the card grows, so a reader at the end stays at the end. Raised over the transcript, which is positioned (z-index 1) and comes first. */ @media (max-width: 768px) { - .terminal-stack.is-chat:not(.is-greeted):has(> .terminal-surface + .prompt-dock:empty + .composer) { + .terminal-stack.is-chat:not(.is-greeted):has(> .terminal-surface + .prompt-dock:empty + .composer:not(.has-notices)) { --composer-over: var(--space-8); } - .terminal-stack.is-chat:not(.is-greeted) > .terminal-surface + .prompt-dock:empty + .composer { + .terminal-stack.is-chat:not(.is-greeted) > .terminal-surface + .prompt-dock:empty + .composer:not(.has-notices) { z-index: 1; margin-top: calc(-1 * var(--composer-over)); background: linear-gradient(transparent, var(--bg) var(--composer-over)); } - .terminal-stack.is-chat:not(.is-greeted) > .terminal-surface + .prompt-dock:empty + .composer .composer-surface { + .terminal-stack.is-chat:not(.is-greeted) > .terminal-surface + .prompt-dock:empty + .composer:not(.has-notices) .composer-surface { background: var(--card-glass); -webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur); diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index b7d05835a..d616e89bf 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -13,7 +13,7 @@ import { type ReactNode, } from "react"; import { flushSync } from "react-dom"; -import { ArrowUp, FileText, Plus, Square, TriangleAlert, X } from "lucide-react"; +import { ArrowUp, FileText, Folder, FolderGit2, GitBranch, LoaderCircle, Minimize2, Plus, Square, TriangleAlert, X } from "lucide-react"; import "./Composer.css"; @@ -38,15 +38,22 @@ import { submitNote, terminalOnlyCommand, } from "../lib/compose.ts"; +import { compactCommandText } from "../lib/compactContext.ts"; +import { composerStrip } from "../lib/composerStrip.ts"; +import { composerLabelDraw } from "../lib/composerLabelFit.ts"; +import { composerFull, formatChars, pasteOverflow } from "../lib/composerLength.ts"; +import { normalizeComposerImageFile } from "../lib/composerImageMime.ts"; import { paneStatus } from "../lib/status.ts"; import { modelLabel } from "../lib/modelName.ts"; import { useFacesArrived } from "../lib/fontFaces.ts"; import { activeTrigger, applyCompletion, type ActiveTrigger } from "../lib/mentions.ts"; +import { resolveComposerMenuActiveItemId } from "../lib/composerMenuHighlight.ts"; import { quickReplyButtons, useSettings } from "../lib/settings.ts"; import { composerUsage, formatPercent, formatResetIn, HIGH_PERCENT, meterPercent, meterText, providerForAgent, statusWindows, usageName, useUsage, windowLabel } from "../lib/usage.ts"; import { AgentMark } from "./AgentMark.tsx"; import { BackgroundTasks } from "./BackgroundTasks.tsx"; import { MicButton, VoiceRecordingPill, useDictation } from "./VoiceInput.tsx"; +import { cachedPaneCommands } from "../lib/paneCommandCache.ts"; import { useT } from "../lib/i18n.ts"; import { modalOpen } from "../lib/focus.ts"; @@ -73,21 +80,31 @@ export interface ComposerProps { restored?: string | null; /** an empty chat's greeting: it stands over the composer's column and takes no row of its own */ greeting?: ReactNode; + /** the pane's notices over the input card (PaneTerminal builds them): rows of its own, above the replies */ + notices?: ReactNode; + /** the pane's working folder, named in the strip tucked under the card; nothing is drawn for it while absent */ + cwd?: string | null; + /** the pane's branch, beside the folder in that strip; nothing is drawn for it while absent */ + branch?: string | null; + /** the workspace is a linked worktree of this repository: the strip names the repository in the folder's + * place (the checkout folder is the branch again, with dashes) and says it is a worktree; null: not one */ + worktreeRepo?: string | null; /** true: sent, clear the box; a string: keep the text and say why; a DraftRefusal: the same, and the - * note offers to send again with `clearDraft`, which clears that draft from the terminal first; a - * promise settles to any of them */ - onSend: (text: string, clearDraft?: string) => boolean | string | DraftRefusal | Promise; + * note offers to send again with `clearDraft`, which clears that draft from the terminal first, or, + * where it named none, with `sendAnyway`; a promise settles to any of them */ + onSend: (text: string, clearDraft?: string, sendAnyway?: boolean) => boolean | string | DraftRefusal | Promise; onAbort: () => void; onUploadImage: (file: File) => Promise; } const UPLOADS_AT_ONCE = 3; +/** files one paste, pick or drop takes; the rest are left out with a note */ +const ATTACH_LIMIT = 100; /** * Any file can be attached (an icon, a PDF, a log): the server stores it beside the pane * and the message mentions its path. These image types also get a thumbnail. */ const PREVIEW_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"] as const; -const COMMAND_CACHE_MS = 60_000; const SLASH_USAGE_KEY = "herdr-web-ui:slash-usage"; /** One height for every pane on this device: it is the screen, not the conversation, that decides it. */ const COMPOSER_HEIGHT_KEY = "herdr-web-ui:composer-height"; @@ -106,8 +123,6 @@ export const SOURCE_LABEL: Record = { plugin: "Plugins", }; -type CommandCacheEntry = { loadedAt: number; commands: SlashCommand[] }; -const commandCache = new Map(); let attachmentSequence = 0; type Attachment = { @@ -115,7 +130,9 @@ type Attachment = { file: File; previewUrl: string; path: string | null; - state: "uploading" | "ready" | "error"; + state: "uploading" | "ready" | "error" | "no-room"; + /** for "no-room": how many characters the message must lose for the path to fit */ + missing?: number; /** what the pick this file came in with left out, repeated beside an upload error of the batch */ leftOut: string | null; }; @@ -157,21 +174,13 @@ function composerHeightLimit(): number { return Math.min(COMPOSER_HEIGHT_MAX, Math.floor(viewport / 2)); } -async function cachedPaneCommands(paneId: string, machineId: string, fetchCommands: (pane: string) => Promise): Promise { - const cached = commandCache.get(paneStorageId(machineId, paneId)); - if (cached && Date.now() - cached.loadedAt < COMMAND_CACHE_MS) return cached.commands; - const commands = await fetchCommands(paneId); - commandCache.set(paneStorageId(machineId, paneId), { loadedAt: Date.now(), commands }); - return commands; -} - /** * What is left of the context, as a ring filled by what is used (as Codex's app shows it): * red when little is left. The number is on hover, and on a tap beside the ring (a touch * screen has no hover). A window the transcript does not name draws no ring. Whether the number * is open is the composer's to keep: its text takes room in the row the model label is fitted to. */ -function ContextRing({ context, shown, readOnly = false, onToggle }: { context: NonNullable; shown: boolean; readOnly?: boolean; onToggle: () => void }) { +function ContextRing({ context, shown, readOnly = false, onToggle, onCompact }: { context: NonNullable; shown: boolean; readOnly?: boolean; onToggle: () => void; onCompact?: (() => void) | undefined }) { const t = useT(); const left = contextLeftPercent(context); if (left === null || context.window === null) return null; @@ -188,6 +197,7 @@ function ContextRing({ context, shown, readOnly = false, onToggle }: { context: {label} · {detail} ; return ( + <> + {shown && onCompact && } + ); } @@ -235,6 +249,10 @@ export function Composer({ suggestion = null, restored = null, greeting = null, + notices = null, + cwd = null, + branch = null, + worktreeRepo = null, onSend, onAbort, onUploadImage, @@ -281,7 +299,10 @@ export function Composer({ const [commands, setCommands] = useState([]); const [files, setFiles] = useState([]); const [slashUsage, setSlashUsage] = useState>(readSlashUsage); - const [storedIndex, setSelectedIndex] = useState(0); + // the highlight lives in the refs below: a move redraws the menu, also when it lands on an index it held before + const [, redrawMenu] = useState(0); + const highlightedItemId = useRef(null); + const highlightedSearchKey = useRef(null); const [menuDismissed, setMenuDismissed] = useState(false); const [attachments, setAttachments] = useState([]); const [dragging, setDragging] = useState(false); @@ -307,6 +328,8 @@ export function Composer({ /** a command the agent runs but the chat cannot finish, while it is what the box holds */ const terminalOnly = useMemo(() => terminalOnlyCommand(agent, text), [agent, text]); const uploading = attachments.some((attachment) => attachment.state === "uploading"); + /** a finished upload whose path waits for the message to be shortened: Send waits with it */ + const roomless = attachments.find((attachment) => attachment.state === "no-room"); const agentLabel = agentDisplayLabel(agent); // the agent's suggestion stands in the empty box as it does in its own input, until anything is typed const offered = connected && answerHint === null && suggestion !== null ? suggestion : null; @@ -369,9 +392,7 @@ export function Composer({ return () => window.clearTimeout(timer); }, [paneId, trigger?.kind, trigger?.query]); - useEffect(() => { - setSelectedIndex(0); - }, [trigger?.kind, trigger?.query]); + const searchKey = trigger === null ? null : `${trigger.kind}:${trigger.prefix ?? ""}:${trigger.query}`; useEffect(() => { mounted.current = true; @@ -405,7 +426,9 @@ export function Composer({ setAutoHeight((current) => current === height ? current : height); }, [manualHeight]); - useLayoutEffect(sizeTextarea, [sizeTextarea, text, placeholder, boxWidth, facesLoaded]); + // A suggestion or prompt hint changes only the placeholder of a populated draft. + // Collapsing it to measure unused placeholder text can scroll iOS's focused caret. + useLayoutEffect(sizeTextarea, [sizeTextarea, text, text.length === 0 ? placeholder : null, boxWidth, facesLoaded]); // SettingsProvider writes --chat-scale in a passive effect. Measure on the next frame, // after that CSS is applied, so a draft rewraps without waiting for another keystroke. @@ -426,10 +449,11 @@ export function Composer({ * Two things the controls row asks of the layout itself, since what fits depends on the * model's name, the mic, the chip and the language, not on a width. * - The model label: measured with everything drawn, then drawn as composerModelDraw says - * (stepped out whole while Queue shows, the effort word alone without it). Stepping out is - * CSS on the mark's attribute, so the label is still read. It runs after every render of the - * composer, which is why the context ring's open number is this component's state: opening it - * takes room from the label without changing the card's size. + * (the effort word steps out first); measured again without it, a name cut to its floor steps + * out whole with its mark (composerLabelDraw), so the ring after it is drawn whole. Stepping + * out is CSS on the mark's attribute, so the label is still read. Drawn content changes fit + * the row again: opening the context number takes room from the label without changing + * the card's size. * - The sentence is never cut. Where it does not fit beside the model it wraps to a line of its * own (CSS); there it is marked, so it takes the whole line and drops the dot that separated * it from the model. The sentence alone wraps: the rest stays one row (.composer-status-meta), @@ -438,29 +462,54 @@ export function Composer({ const fitStatus = useCallback((): void => { const status = statusRef.current; if (!status) return; - status.removeAttribute("data-model"); - status.removeAttribute("data-hint-alone"); - status.removeAttribute("data-meta-empty"); - const clipped = (selector: string): boolean => { - const item = status.querySelector(selector); - return item !== null && item.scrollWidth > item.clientWidth; - }; - const draw = composerModelDraw({ - modelClipped: clipped(".composer-model"), - effortClipped: clipped(".composer-reasoning"), - }); - if (draw !== "full") status.setAttribute("data-model", draw); - const hint = hintRef.current; - if (!hint) return; - // with the label stepped out and no ring, the row beside the sentence draws nothing: it gives - // up its box, or it would keep an empty line over the sentence - const meta = status.querySelector(".composer-status-meta")?.getBoundingClientRect(); - if (!meta || meta.width <= 1) status.setAttribute("data-meta-empty", ""); - const line = hint.getBoundingClientRect(); - const beside = meta !== undefined && meta.width > 1 && meta.bottom > line.top && meta.top < line.top + line.height / 2 && meta.right <= line.left + 1; - if (!beside) status.setAttribute("data-hint-alone", ""); + // Fitting temporarily exposes hidden labels and unwraps hints. Keep those measurement + // layouts from moving the focused textarea before the final attributes are restored. + const surface = surfaceRef.current; + const rows = surface?.style.gridTemplateRows ?? ""; + if (surface) surface.style.gridTemplateRows = getComputedStyle(surface).gridTemplateRows; + try { + status.removeAttribute("data-model"); + status.removeAttribute("data-hint-alone"); + status.removeAttribute("data-meta-empty"); + const clipped = (selector: string): boolean => { + const item = status.querySelector(selector); + return item !== null && item.scrollWidth > item.clientWidth; + }; + const first = composerModelDraw({ + modelClipped: clipped(".composer-model"), + effortClipped: clipped(".composer-reasoning"), + }); + if (first !== "full") status.setAttribute("data-model", first); + // with the level stepped out the row is laid out again: a name still cut to its floor (its + // min-width, in px once computed) takes the mark and the hairlines out with it + const model = status.querySelector(".composer-model"); + const draw = model === null ? first : composerLabelDraw(first, { + modelClipped: model.scrollWidth > model.clientWidth, + modelWidth: model.clientWidth, + modelFloor: parseFloat(getComputedStyle(model).minWidth) || 0, + }); + if (draw !== first) status.setAttribute("data-model", draw); + const hint = hintRef.current; + if (!hint) return; + // with the label stepped out and no ring, the row beside the sentence draws nothing: it gives + // up its box, or it would keep an empty line over the sentence + const meta = status.querySelector(".composer-status-meta")?.getBoundingClientRect(); + if (!meta || meta.width <= 1) status.setAttribute("data-meta-empty", ""); + const line = hint.getBoundingClientRect(); + const beside = meta !== undefined && meta.width > 1 && meta.bottom > line.top && meta.top < line.top + line.height / 2 && meta.right <= line.left + 1; + if (!beside) status.setAttribute("data-hint-alone", ""); + } finally { + if (surface) surface.style.gridTemplateRows = rows; + } }, []); - useLayoutEffect(fitStatus); + + // SettingsProvider applies typography in a passive effect. Fit after those CSS tokens land, + // even when the card's total width stays the same, without fitting on streaming renders. + useEffect(() => { + const frame = requestAnimationFrame(fitStatus); + return () => cancelAnimationFrame(frame); + }, [fitStatus, settings.uiFontSize, + settings.chatFontSize, settings.chatFontFamily, settings.density]); // the card's own width decides, not the window's: a sidebar or a narrow lane shrinks the card // in a wide window. Measured before the first paint, so a phone never draws the words first. @@ -470,9 +519,13 @@ export function Composer({ const surface = surfaceRef.current; if (!surface) return; let frame = 0; - setCardWidth(Math.round(surface.getBoundingClientRect().width)); + let width = Math.round(surface.getBoundingClientRect().width); + setCardWidth(width); const observer = new ResizeObserver(() => { - setCardWidth(Math.round(surface.getBoundingClientRect().width)); + const next = Math.round(surface.getBoundingClientRect().width); + if (next === width) return; + width = next; + setCardWidth(width); cancelAnimationFrame(frame); frame = requestAnimationFrame(fitStatus); }); @@ -571,7 +624,23 @@ export function Composer({ const menuOpen = !menuDismissed && trigger !== null && choices.length > 0; // clamped as the list is drawn, not by an effect after it: a Tab or Enter typed before that // effect's render found no row at the old index and did nothing - const selectedIndex = Math.min(storedIndex, Math.max(0, choices.length - 1)); + const choiceIds = choices.map((choice) => trigger?.kind === "slash" + ? `${(choice as SlashCommand).source}:${(choice as SlashCommand).name}` + : choice as string); + const activeItemId = resolveComposerMenuActiveItemId({ + items: choiceIds.map((id) => ({ id })), + highlightedItemId: searchKey === null ? null : highlightedItemId.current, + currentSearchKey: searchKey, + highlightedSearchKey: highlightedSearchKey.current, + }); + const selectedIndex = activeItemId === null ? 0 : choiceIds.indexOf(activeItemId); + if (activeItemId !== null && searchKey !== null) { + highlightedItemId.current = activeItemId; + highlightedSearchKey.current = searchKey; + } else { + highlightedItemId.current = null; + highlightedSearchKey.current = searchKey; + } // dictation lands at the caret without taking focus (a phone's keyboard stays as it was) const dictation = useDictation({ @@ -598,6 +667,9 @@ export function Composer({ onNote: setNote, }); + /** the note for a paste the box cut, held across the change event that follows it */ + const pasteCutNote = useRef(null); + const setTextAndCaret = useCallback((nextText: string, nextCaret: number) => { const limitedText = nextText.slice(0, MAX_COMPOSER_CHARS); const clampedCaret = Math.min(nextCaret, MAX_COMPOSER_CHARS); @@ -618,8 +690,8 @@ export function Composer({ // an upload finishes while the user may be typing: its mention goes to the start (insertLeadingMention), // and the caret and selection only move along with the text they sit in, without taking focus - // `false` when the message has no room for the whole mention: nothing is written then - const insertMentionAtStart = useCallback((mention: string, earlier: readonly string[]): boolean => { + /** 0 once inserted; otherwise how many characters short the message is, and nothing changed */ + const insertMentionAtStart = useCallback((mention: string, earlier: readonly string[]): number => { const element = textareaRef.current; const currentText = textRef.current; const selectionIsCurrent = element?.value === currentText; @@ -627,7 +699,7 @@ export function Composer({ const end = selectionIsCurrent ? (element.selectionEnd ?? start) : start; const direction = selectionIsCurrent ? (element.selectionDirection ?? undefined) : undefined; const next = insertLeadingMention(currentText, mention, earlier); - if (next.length === 0) return false; + if (next.missing !== undefined) return next.missing; const moved = (position: number) => (position >= next.at ? position + next.length : position); textRef.current = next.text; caretRef.current = moved(start); @@ -636,7 +708,7 @@ export function Composer({ setCaret(moved(start)); }); if (element) element.setSelectionRange(moved(start), moved(end), direction); - return true; + return 0; }, []); // one queue for the whole box: a pick made while another uploads waits its turn @@ -644,7 +716,7 @@ export function Composer({ const uploadsRunning = useRef(0); /** paths placed so far, by attachment id: ids follow pick order */ const placedMentions = useRef(new Map()); - /** finished uploads whose paths wait for a /command or an IME composition to end */ + /** finished uploads whose paths wait for a /command or an IME composition to end, or for room */ const waitingMentions = useRef<{ id: number; path: string }[]>([]); const placeWaitingMentions = useCallback(() => { @@ -656,10 +728,15 @@ export function Composer({ if (removedAttachments.current.has(id)) continue; const mention = imageMention(path); const earlier = [...placedMentions.current].filter(([placed]) => placed < id).map(([, placed]) => placed); - // no room for the whole path: this attachment and the ones picked after it stay uploading, in + // no room for the whole path: this attachment and the ones picked after it stay waiting, in // their order, Send stays off, and the next edit of the text tries again (the effect below); - // a cut path would name a file that is not there - if (!insertMentionAtStart(mention, earlier)) { waitingMentions.current.push(...waiting.slice(index)); break; } + // a cut path would name a file that is not there, so the tile says how much to cut instead + const missing = insertMentionAtStart(mention, earlier); + if (missing > 0) { + waitingMentions.current.push(...waiting.slice(index)); + setAttachments((current) => current.map((item) => (item.id === id ? { ...item, state: "no-room", missing } : item))); + break; + } placedMentions.current.set(id, mention); setAttachments((current) => current.map((item) => (item.id === id ? { ...item, path, state: "ready" } : item))); } @@ -726,27 +803,32 @@ export function Composer({ const uploadImages = useCallback( (incoming: readonly File[]) => { - const images = incoming.slice(0, settings.attachLimit); + const images = incoming.slice(0, ATTACH_LIMIT); if (images.length === 0) return; // the files left out of this pick: told now, and again beside an upload error of this batch const leftOut = images.length < incoming.length - ? t("Only the first {count} of {total} files were taken. Settings → Chat → Files at once sets how many one paste, pick or drop takes.", { count: images.length, total: incoming.length }) + ? t("Only the first {count} of {total} files were taken.", { count: images.length, total: incoming.length }) : null; - const added = images.map((file) => ({ - id: ++attachmentSequence, - file, - previewUrl: (PREVIEW_TYPES as readonly string[]).includes(file.type) ? URL.createObjectURL(file) : "", - path: null, - state: "uploading", - leftOut, - })); + const added = images.map((file) => { + const uploadFile = normalizeComposerImageFile(file); + return { + id: ++attachmentSequence, + file: uploadFile, + previewUrl: (PREVIEW_TYPES as readonly string[]).includes(uploadFile.type) + ? URL.createObjectURL(uploadFile) + : "", + path: null, + state: "uploading", + leftOut, + }; + }); setAttachments((current) => [...current, ...added]); setNote(leftOut); uploadQueue.current.push(...added); pumpUploads(); }, - [pumpUploads, settings.attachLimit, t], + [pumpUploads, t], ); const removeAttachment = useCallback((attachment: Attachment) => { @@ -762,13 +844,18 @@ export function Composer({ caretRef.current = Math.min(caretRef.current, next.length); return next; }); + } else { + // removing a path that had no room leaves the text as it was, so the paths waiting behind it + // would wait for the next edit: try them now + placeWaitingMentions(); } - }, []); + }, [placeWaitingMentions]); - /** `clearDraft`: the terminal draft a DraftRefusal named, which the user asked to clear and send over */ - const send = useCallback((clearDraft?: string) => { + /** `clearDraft`: the terminal draft a DraftRefusal named, which the user asked to clear and send over; + * `sendAnyway`: the user saw the box a DraftRefusal showed and sends over it */ + const send = useCallback((clearDraft?: string, sendAnyway = false) => { if (composingRef.current) return; - if (!connected || uploading || sending || text.trim().length === 0) return; + if (!connected || uploading || roomless || sending || text.trim().length === 0) return; const sent = text; const sentAttachments = attachments; const settle = (result: boolean | string | DraftRefusal): void => { @@ -790,14 +877,14 @@ export function Composer({ // a polish landing before the acknowledgement would count as an edit and keep the sent message here dictation.forget(); try { - const result = onSend(text, clearDraft); + const result = onSend(text, clearDraft, sendAnyway); if (!(result instanceof Promise)) { settle(result); composerDrafts.end(draftKey); return; } void result.then(settle).catch(() => { if (mounted.current) setNote(t("Not confirmed. Check the terminal before sending again.")); }).finally(() => composerDrafts.end(draftKey)); } catch { composerDrafts.end(draftKey); if (mounted.current) setNote(t("Not confirmed. Check the terminal before sending again.")); } - }, [attachments, connected, dictation.forget, draftKey, onSend, sending, text, uploading]); + }, [attachments, connected, dictation.forget, draftKey, onSend, roomless, sending, text, uploading]); /** A quick reply follows the same delivery policy as Send, and leaves the box alone. */ const sendQuick = useCallback((reply: string) => { @@ -826,7 +913,10 @@ export function Composer({ if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const direction = event.key === "ArrowDown" ? 1 : -1; - setSelectedIndex((current) => (Math.min(current, choices.length - 1) + direction + choices.length) % choices.length); + const nextIndex = (selectedIndex + direction + choices.length) % choices.length; + redrawMenu((count) => count + 1); + highlightedItemId.current = choiceIds[nextIndex] ?? null; + highlightedSearchKey.current = searchKey; return; } if (event.key === "Enter" || event.key === "Tab") { @@ -870,6 +960,15 @@ export function Composer({ const onPaste = useCallback( (event: ClipboardEvent) => { + const box = event.currentTarget; + const over = pasteOverflow(box.value, box.selectionStart, box.selectionEnd, event.clipboardData.getData("text"), MAX_COMPOSER_CHARS); + if (over > 0) { + // the box cuts the paste itself; the change event it fires would clear a note, so this one is kept for it + const cut = t("{count} characters of the paste did not fit: a message holds {max}.", { count: formatChars(over), max: formatChars(MAX_COMPOSER_CHARS) }); + pasteCutNote.current = cut; + setNote(cut); + setTimeout(() => { pasteCutNote.current = null; }, 0); + } const images = Array.from(event.clipboardData.items) .filter((item) => item.kind === "file") .map((item) => item.getAsFile()) @@ -878,7 +977,7 @@ export function Composer({ event.preventDefault(); void uploadImages(images); }, - [uploadImages], + [t, uploadImages], ); const onDrop = useCallback( @@ -891,11 +990,14 @@ export function Composer({ ); const isWorking = agentStatus === "working"; + // an explicit click sends /compact the way a typed message goes; only where the agent lists it, never while it works + const compactText = compactCommandText(commands); + const compactNow = compactText !== null && connected && !sending && !isWorking && agentStatus !== "blocked" ? () => sendQuick(compactText) : undefined; // what the status line says; what may be sent, queued or stopped goes by the agent's own status const shownStatus = paneStatus({ agent_status: agentStatus, ...(backgroundWait ? { background_wait: true as const } : {}) }); const statusCompact = composerStatusCompact(cardWidth); - const sendShown = composerSendShown({ working: isWorking, text }); - const hint = composerStatusHint({ uploading, connected, text }); + const sendShown = sending || composerSendShown({ working: isWorking, text }); + const hint = composerStatusHint({ uploading, connected, text, noRoom: roomless !== undefined }); const model = metadata?.model ? modelLabel(metadata.model) : null; const modelShown = Boolean(metadata?.model || metadata?.reasoning_effort); const usageDetail = usageWindows.map((window) => { @@ -904,7 +1006,19 @@ export function Composer({ }).join("\n"); const usageCaption = usageLimit === undefined ? "" : `${usageLimit.kind === "session" ? "5h" : windowLabel(usageLimit)} ${formatPercent(meterPercent(usageLimit, settings.usageCount))}`; const usageAccessible = usage === undefined ? "" : `${t("Subscription usage")}: ${usageName(usage)}, ${usageDetail.replaceAll("\n", ", ")}`; - const hintText = hint === null ? null : t(hint === "uploading" ? "Uploading file…" : "Reconnecting… message held here, never queued"); + const hintText = hint === null ? null + : hint === "no-room" ? t("No room for {file}: remove {count} characters", { file: roomless?.file.name ?? "", count: roomless?.missing ?? 0 }) + : hint === "near-limit" ? t("{count} characters left", { count: MAX_COMPOSER_CHARS - text.length }) + : t(hint === "uploading" ? "Uploading file…" : "Reconnecting… message held here, never queued"); + /** the strip under the card: the folder and the branch the caller knows; none, or a phone's screen, no strip */ + const strip = mobile ? null : composerStrip({ cwd, branch, repo: worktreeRepo }); + const worktree = worktreeRepo !== null; + // Fit only changes to drawn content. Streaming context usage is a fixed-size ring unless + // its number is open on desktop; transcript/status refreshes need no measurement layout. + useLayoutEffect(fitStatus, [fitStatus, agent, metadata?.model, metadata?.reasoning_effort, + metadata?.context?.window, contextShown && !mobile && metadata?.context ? contextLeftPercent(metadata.context) : null, + contextShown, mobile, usageCaption, usage?.problem, hintText, dictation.shown, backgroundTasks, + plan?.done, plan?.total, statusCompact, compactNow !== undefined, facesLoaded, t]); const menuId = `composer-menu-${paneId}`; // the arrow keys move the selection, not the focus (it stays in the box), so the browser does not scroll to it useEffect(() => { @@ -912,10 +1026,11 @@ export function Composer({ }, [menuOpen, menuId, selectedIndex, choices]); return ( -
    +
    {greeting} - {/* no Tab key on a phone: the suggestion is a chip there that fills the box, unless Settings turns it off */} - {settings.showSuggestionChip && offered !== null && text === "" && ( + {notices} + {/* no Tab key on a phone: the suggestion is a chip there that fills the box */} + {offered !== null && text === "" && (
    } + {/* the draft named above, never what has replaced it since: the server checks; a box it could + not tell empty, sent over only in the same Claude terminal the refusal was for */} + {typeof note === "object" && }
    )} @@ -1042,7 +1158,7 @@ export function Composer({ {attachment.previewUrl ? {attachment.file.name} : } - {t(attachment.state === "uploading" ? "Uploading" : attachment.state === "error" ? "Failed" : "Attached")} + {t(attachment.state === "uploading" ? "Uploading" : attachment.state === "error" ? "Failed" : attachment.state === "no-room" ? "No room" : "Attached")} )}
    + {/* the strip tucked under the card (t3code's composer context strip): the pane's working folder and its + branch, each drawn only when the caller passes it. It only names them; nothing in it is pressed */} + {strip !== null && ( +
    + {strip.folder !== null && + {worktree ? } + {strip.branch !== null && + } +
    + )} {/* said while typing, before the send: after it the browser is already open and the reader is already in the state the words describe. Not a block — the text still goes, and pi runs the command in the terminal the way its own palette would */} + {/* why typing stopped: the box clamps at its cap without a word, so the box says it */} + {!note && composerFull(text.length, MAX_COMPOSER_CHARS) && ( +
    {t("Message limit reached: {max} characters. Nothing more can be added.", { max: formatChars(MAX_COMPOSER_CHARS) })}
    + )} {!note && terminalOnly !== null && (
    {t("{command} opens a tree the chat cannot show. It runs in the terminal — tap the terminal button at the top of the screen to choose a branch.", { command: `/${terminalOnly}` })}
    )} diff --git a/src/components/ComposerNotices.css b/src/components/ComposerNotices.css new file mode 100644 index 000000000..98f062614 --- /dev/null +++ b/src/components/ComposerNotices.css @@ -0,0 +1,146 @@ +/* Notices over the input card (ComposerNotices.tsx): what the terminal lens floats top-right + (PaneTerminal.css) stands here under the chat lens, as rows of the composer's in the held + messages' anatomy (Composer.css): one hairline above the group, a row per notice, every line of + text --space-3 in, no box. The plain notice is the top row and the activity stands on the card + (lib/composerNotices.ts). The column's width is the composer's rule (Composer.css); the resize + grip's hit area rises above the box, so the group stops short of it and a row's buttons are whole */ +.composer-notices { + display: flex; + flex-direction: column; + margin-bottom: max(var(--space-2), var(--composer-grip-h)); + padding-top: var(--space-1); + border-top: var(--hairline) solid var(--border); +} + +.composer-notice { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-1) var(--space-2); + min-height: var(--control-h); + padding-left: var(--space-3); + color: var(--text-dim); + font-size: var(--fs-xs); + line-height: var(--lh-base); +} + +.composer-notice + .composer-notice { + border-top: var(--hairline) solid var(--border); +} + +/* a warning (another app holds the pane, the output stream failed): the terminal pill's colour */ +.composer-notice.is-warning { + color: var(--status-working); +} + +.composer-notice-mark { + display: inline-flex; + flex: none; +} + +.composer-notice-mark svg { + width: 1em; + height: 1em; +} + +.composer-notice-text { + flex: 1 1 12rem; + min-width: 0; + overflow-wrap: anywhere; +} + +/* held input: its start in mono, cut as the terminal pill cuts it */ +.composer-notice-text code { + display: inline-block; + max-width: 32ch; + overflow: hidden; + vertical-align: bottom; + font-family: var(--font-mono); + color: var(--text); + text-overflow: ellipsis; + white-space: nowrap; +} + +.composer-notice-actions { + display: inline-flex; + flex: none; + align-items: center; + gap: var(--space-1); + margin-left: auto; +} + +/* a notice's own way on (Open here, Reconnect, Send) in the held rows' button; Discard is the quiet one */ +.composer-notice-action { + display: inline-flex; + align-items: center; + min-height: var(--control-h); + padding: 0 var(--space-3); + border: var(--hairline) solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-elevated); + color: var(--text); + font: inherit; + font-size: var(--fs-xs); + font-weight: var(--fw-medium); + text-decoration: none; + cursor: pointer; +} + +.composer-notice-action.is-quiet { + border-color: transparent; + background: transparent; + color: var(--text-dim); +} + +.composer-notice-action:disabled { + opacity: 0.55; + cursor: default; +} + +.composer-notice-dismiss { + display: inline-grid; + flex: none; + place-items: center; + width: var(--control-h); + height: var(--control-h); + padding: 0; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-dim); + cursor: pointer; +} + +.composer-notice-dismiss svg { + width: var(--fs-md); + height: var(--fs-md); +} + +@media (hover: hover) { + .composer-notice-action:hover:not(:disabled) { + border-color: var(--accent); + } + + .composer-notice-action.is-quiet:hover, + .composer-notice-dismiss:hover { + background: var(--bg-hover); + color: var(--text); + } +} + +.composer-notice-action:focus-visible, +.composer-notice-dismiss:focus-visible { + outline: var(--ring); + outline-offset: var(--ring-offset); +} + +@media (pointer: coarse) { + .composer-notice-action { + min-height: var(--touch-target); + } + + .composer-notice-dismiss { + width: var(--touch-target); + height: var(--touch-target); + } +} diff --git a/src/components/ComposerNotices.tsx b/src/components/ComposerNotices.tsx new file mode 100644 index 000000000..a080f55ae --- /dev/null +++ b/src/components/ComposerNotices.tsx @@ -0,0 +1,44 @@ +import { X } from "lucide-react"; +import type { ReactNode } from "react"; + +import "./ComposerNotices.css"; + +import { isDismissible, type ComposerNotice } from "../lib/composerNotices.ts"; +import { useT } from "../lib/i18n.ts"; + +export interface ComposerNoticeRow extends ComposerNotice { + /** another app holds the pane, the output stream failed: the terminal pill's warning colour */ + warning: boolean; + mark: ReactNode; + text: ReactNode; + actions?: ReactNode; +} + +interface ComposerNoticesProps { + /** as arranged by lib/composerNotices.ts: the top row first */ + notices: readonly ComposerNoticeRow[]; + onDismiss: (notice: ComposerNoticeRow) => void; +} + +/** The notices over the input card: a row per notice, its own way on at its end, then its dismiss (an activity has none). */ +export function ComposerNotices({ notices, onDismiss }: ComposerNoticesProps) { + const t = useT(); + return ( +
    + {notices.map((notice) => ( +
    + + {notice.text} + + {notice.actions} + {isDismissible(notice) && ( + + )} + +
    + ))} +
    + ); +} diff --git a/src/components/DevicesPanel.tsx b/src/components/DevicesPanel.tsx index 7a2c72c4a..109298a5e 100644 --- a/src/components/DevicesPanel.tsx +++ b/src/components/DevicesPanel.tsx @@ -50,9 +50,11 @@ export function DevicesPanel({ pairUrl, auth, onPaired }: DevicesPanelProps) { const [renaming, setRenaming] = useState<{ id: string; label: string } | null>(null); const [confirming, setConfirming] = useState(null); const [justPaired, setJustPaired] = useState(null); + const [pairingSelf, setPairingSelf] = useState(false); const [linkCopied, setLinkCopied] = useState(false); const linkRef = useRef(null); const known = useRef(new Set()); + const pairingSelfLock = useRef(false); const load = useCallback(async () => { try { @@ -91,12 +93,28 @@ export function DevicesPanel({ pairUrl, auth, onPaired }: DevicesPanelProps) { }; /** an "open" browser pairs itself: a code, used at once, so the gate closes to everyone else */ const pairSelf = async () => { + if (pairingSelfLock.current) return; + pairingSelfLock.current = true; + setPairingSelf(true); + setError(null); try { const fresh = await startPairing(); await pairDevice(fresh.code, deviceLabel(navigator.userAgent, navigator.maxTouchPoints ?? 0)); - await load(); + const list = await fetchDevices(); + const current = list.find((device) => device.current); + setDevices(list); + known.current = new Set(list.map((device) => device.id)); + if (!current) { + setError(t("Could not confirm this browser is paired. Check that cookies are enabled, then try again.")); + return; + } + setJustPaired(current.label); onPaired?.(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } + finally { + pairingSelfLock.current = false; + setPairingSelf(false); + } }; const rename = async () => { if (!renaming) return; @@ -114,7 +132,10 @@ export function DevicesPanel({ pairUrl, auth, onPaired }: DevicesPanelProps) { return (
    {auth?.via !== undefined &&

    {t(VIA[auth.via])}

    } - {auth?.via === "open" &&
    } + {auth?.via === "open" && <> +

    {t("Pairing the first device enables app-level access restrictions, even behind proxy authentication. Other browsers need a paired device or an access token.")}

    +
    + }

    {t("A paired device gets in on its own, from anywhere it can reach this server, until you revoke it here. A code comes from this screen on the PC or on a device already paired, or from the pair command in the PC's terminal.")}

    {devices === null ?

    {t("Loading…")}

    : devices.length === 0 ? (

    {t("No devices paired yet.")}

    diff --git a/src/components/DiffWorkerPool.tsx b/src/components/DiffWorkerPool.tsx new file mode 100644 index 000000000..ae8a5de57 --- /dev/null +++ b/src/components/DiffWorkerPool.tsx @@ -0,0 +1,28 @@ +import { useEffect, type ReactNode } from "react"; +import { WorkerPoolContext } from "@pierre/diffs/react"; +import { WorkerPoolManager } from "@pierre/diffs/worker"; + +import { createIdleLease } from "../lib/idleLease.ts"; +import { HIGHLIGHTER_OPTIONS, POOL_OPTIONS } from "../lib/sourceTheme.ts"; + +/** How long the workers stay after the last virtualized source or diff closes. */ +const IDLE_MS = 30_000; + +/** + * One worker pool for the whole app, kept in this lazy chunk (the app shell never imports the + * package): every RepoDiff and VirtualSource shares it (plain ones for its windowed rows), and a closed viewer releases it + * instead of ending it, so a reopen within the idle time finds warm workers and their highlight + * cache. The highlighter's theme is the app's CSS-variables theme, the same in light and dark, so + * a theme switch never re-initialises a worker. + */ +const pool = createIdleLease( + () => new WorkerPoolManager(POOL_OPTIONS, HIGHLIGHTER_OPTIONS), + (manager) => { manager.terminate(); }, + IDLE_MS, +); + +export function DiffWorkerPool({ children }: { children: ReactNode }) { + const manager = pool.get(); + useEffect(() => pool.hold(), [manager]); + return {children}; +} diff --git a/src/components/DirectoryBrowser.tsx b/src/components/DirectoryBrowser.tsx index 1c34f4571..a24a8283c 100644 --- a/src/components/DirectoryBrowser.tsx +++ b/src/components/DirectoryBrowser.tsx @@ -1,13 +1,18 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { ArrowUp, FileText, Folder, House } from "lucide-react"; +import { ArrowUp, House } from "lucide-react"; import "./DirectoryBrowser.css"; +import { FileGlyph } from "./FileGlyph.tsx"; import type { DirectoryListing } from "../../shared/protocol.ts"; import { ApiError } from "../lib/api.ts"; import { formatBytes } from "../lib/bridgeProgress.ts"; import { useMachineApi } from "../lib/machineContext.tsx"; import { useT } from "../lib/i18n.ts"; +import { resolveWorkspaceRoot, workspaceRelativePath } from "../lib/workspacePath.ts"; +import { homeRelative, subscribeFilesFolder, takeFilesFolder } from "../lib/paletteFolders.ts"; + +export { homeRelative }; export interface DirectoryBrowserProps { /** where to open: the path typed so far (absolute, `~` or `~/…`); empty or unreadable opens home */ @@ -16,12 +21,8 @@ export interface DirectoryBrowserProps { onPick?: (path: string) => void; /** lists files too, and opens the one clicked (its absolute path) */ onOpenFile?: (path: string) => void; -} - -/** `~/…` for a path inside home, as the directory field is usually typed. */ -export function homeRelative(path: string, home: string): string { - if (path === home) return "~"; - return path.startsWith(`${home}/`) ? `~/${path.slice(home.length + 1)}` : path; + /** told the folder shown (its absolute path) each time one opens */ + onFolder?: (path: string) => void; } function childPath(parent: string, name: string): string { @@ -32,7 +33,7 @@ function childPath(parent: string, name: string): string { * A folder browser for the new-session dialog: one directory at a time, fetched from the * PC the session starts on. Nothing is kept but the folder shown now. */ -export function DirectoryBrowser({ start, onPick, onOpenFile }: DirectoryBrowserProps) { +export function DirectoryBrowser({ start, onPick, onOpenFile, onFolder }: DirectoryBrowserProps) { const t = useT(); const { fetchDirectories } = useMachineApi(); const [listing, setListing] = useState(null); @@ -42,6 +43,8 @@ export function DirectoryBrowser({ start, onPick, onOpenFile }: DirectoryBrowser const [error, setError] = useState(null); const request = useRef(0); const listRef = useRef(null); + const shownPath = listing?.path; + useEffect(() => { if (shownPath !== undefined) onFolder?.(shownPath); }, [shownPath]); const open = useCallback(async (path: string, showHidden: boolean, fallbackHome: boolean) => { const id = ++request.current; @@ -65,10 +68,26 @@ export function DirectoryBrowser({ start, onPick, onOpenFile }: DirectoryBrowser } }, [fetchDirectories, listing, onOpenFile, t]); - useEffect(() => { void open(start, false, true); }, []); + // Files opens on a folder the palette picked (lib/paletteFolders.ts) instead of the pane's own, + // also when it is open already + const openRef = useRef(open); + openRef.current = open; + useEffect(() => { + const requested = onOpenFile ? takeFilesFolder() : null; + void open(requested ?? start, false, requested === null); + if (!onOpenFile) return; + return subscribeFilesFolder(() => { + const next = takeFilesFolder(); + if (next === null) return; + setHidden(false); + void openRef.current(next, false, false); + }); + }, []); const path = listing?.path ?? ""; - const shown = listing ? homeRelative(listing.path, listing.home) : start || "~"; + const homeShown = listing ? homeRelative(listing.path, listing.home) : start || "~"; + // browsing files reads from the folder opened on; picking a folder keeps the `~/…` form it hands back + const shown = listing && onPick === undefined ? workspaceRelativePath(listing.path, resolveWorkspaceRoot(start, listing.home), homeShown) : homeShown; const normalizedQuery = query.trim().toLowerCase(); const directories = listing === null || onPick === undefined || normalizedQuery === "" ? listing?.directories @@ -114,7 +133,7 @@ export function DirectoryBrowser({ start, onPick, onOpenFile }: DirectoryBrowser {directories?.map((name) => (
  • @@ -122,7 +141,7 @@ export function DirectoryBrowser({ start, onPick, onOpenFile }: DirectoryBrowser {onOpenFile !== undefined && listing?.files?.map((file) => (
  • diff --git a/src/components/Droplet.css b/src/components/Droplet.css index fa482d4dc..f5dbb9031 100644 --- a/src/components/Droplet.css +++ b/src/components/Droplet.css @@ -14,6 +14,8 @@ --droplet-h: 64px; --droplet-drop: 44px; --droplet-radius: 24px; + --droplet-close: 28px; + --droplet-close-inset: var(--space-2); position: fixed; inset: 0 0 auto 0; height: 0; @@ -78,7 +80,7 @@ grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); - padding: 0 var(--space-5) 0 var(--space-4); + padding: 0 calc(var(--droplet-close) + var(--droplet-close-inset) + var(--space-3)) 0 var(--space-4); border: 0; background: transparent; color: var(--droplet-text); @@ -90,8 +92,46 @@ -webkit-tap-highlight-color: transparent; } +/* a sibling of the card (a button holds no button), laid over its right end */ +.droplet-close { + position: absolute; + top: calc(var(--droplet-top) + (var(--droplet-h) - var(--droplet-close)) / 2); + left: calc(50% + var(--droplet-w) / 2 - var(--droplet-close) - var(--droplet-close-inset)); + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--droplet-close); + height: var(--droplet-close); + padding: 0; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: var(--droplet-text-dim); + cursor: pointer; + pointer-events: auto; + translate: 0 var(--droplet-drag, 0px); + -webkit-tap-highlight-color: transparent; + animation: droplet-reveal 260ms 560ms var(--ease-out) both; +} + +.droplet-close:hover { + background: var(--droplet-mark-bg); + color: var(--droplet-text); +} + +.droplet-close:focus-visible { + outline: var(--ring); + outline-offset: var(--ring-offset); +} + +.droplet[data-phase="out"] .droplet-close { + animation: droplet-hide 160ms var(--ease-out) both; + pointer-events: none; +} + .droplet:not([data-dragging]) .droplet-blob, -.droplet:not([data-dragging]) .droplet-card { +.droplet:not([data-dragging]) .droplet-card, +.droplet:not([data-dragging]) .droplet-close { transition: translate 240ms var(--ease-out); } @@ -187,7 +227,7 @@ gap: var(--space-2); width: max-content; max-width: var(--droplet-max); - padding: 0 var(--space-4) 0 var(--space-2); + padding: 0 calc(var(--droplet-close) + var(--droplet-close-inset) + var(--space-2)) 0 var(--space-2); } .droplet-mark { @@ -245,16 +285,19 @@ .droplet-goo { filter: drop-shadow(0 10px 14px var(--droplet-shadow)); } .droplet-blob, .droplet-card::before, - .droplet-card > * { + .droplet-card > *, + .droplet-close { animation: droplet-reveal 160ms var(--ease-out) both; } .droplet[data-phase="out"] .droplet-blob, .droplet[data-phase="out"] .droplet-card::before, - .droplet[data-phase="out"] .droplet-card > * { + .droplet[data-phase="out"] .droplet-card > *, + .droplet[data-phase="out"] .droplet-close { animation: droplet-hide 160ms var(--ease-out) both; } .droplet:not([data-dragging]) .droplet-blob, - .droplet:not([data-dragging]) .droplet-card { + .droplet:not([data-dragging]) .droplet-card, + .droplet:not([data-dragging]) .droplet-close { transition: none; } } diff --git a/src/components/Droplet.tsx b/src/components/Droplet.tsx index 573a15e76..cfcb304a0 100644 --- a/src/components/Droplet.tsx +++ b/src/components/Droplet.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"; +import { X } from "lucide-react"; import { flickVelocity, onDroplet, type PressSample, type QueuedDroplet } from "../lib/droplet.ts"; import { useT } from "../lib/i18n.ts"; import { AgentMark } from "./AgentMark.tsx"; @@ -8,7 +9,7 @@ import "./Droplet.css"; * The in-app alert (lib/droplet.ts): a pill appears and spreads into a card, * below the native safe area and app header, without imitating the physical camera cutout. * One shows at a time; a newer one folds the current one away and takes its place. - * A tap opens the pane, a flick up puts it away, and it leaves by itself after a while. + * A tap opens the pane, a flick up or its close button puts it away, and it leaves by itself after a while. */ /** enter: the drop falls, then spreads, then its text shows (Droplet.css runs the same clock) */ @@ -199,6 +200,9 @@ export function Droplet({ onOpen }: { onOpen: (machineId: string, paneId: string
  • ); } diff --git a/src/components/FileGlyph.tsx b/src/components/FileGlyph.tsx new file mode 100644 index 000000000..b3ed4db22 --- /dev/null +++ b/src/components/FileGlyph.tsx @@ -0,0 +1,41 @@ +import { + File, FileArchive, FileAudio, FileCode, FileCog, FileDiff, FileDigit, FileImage, FileJson, FileKey, FileLock, + FileSpreadsheet, FileTerminal, FileText, FileType, FileVideo, Folder, FolderGit, type LucideIcon, +} from "lucide-react"; + +import { fileIconKind, folderIconKind, type FileIconKind, type FolderIconKind } from "../lib/fileIcons.ts"; + +const FILE_GLYPHS: Record = { + file: File, code: FileCode, json: FileJson, config: FileCog, text: FileText, image: FileImage, video: FileVideo, + audio: FileAudio, archive: FileArchive, shell: FileTerminal, lock: FileLock, diff: FileDiff, font: FileType, + key: FileKey, binary: FileDigit, spreadsheet: FileSpreadsheet, +}; + +const FOLDER_GLYPHS: Record = { folder: Folder, git: FolderGit }; + +export interface FileGlyphProps { + /** the entry's name, or a path, whose last part names it */ + name: string; + kind?: "file" | "directory"; + className?: string; +} + +/** + * The glyph a list draws before an entry's name (lib/fileIcons.ts decides which), hidden from + * assistive tech since the name beside it says the same. The row's CSS sizes and colors it, as it + * does any other lucide glyph; `data-kind` names the kind for a style or a script that reads it. + */ +export function FileGlyph({ name, kind = "file", className }: FileGlyphProps) { + const [Glyph, glyphKind] = kind === "directory" ? folderGlyph(name) : fileGlyph(name); + return
    ; const textFile = info?.kind === "text"; - const language = languageForPath(info?.path ?? path); + const language = languageForPath(info?.path ?? target.path); // a Preview its parser gave up on (too slow, or failed) opens the source instead const preview = useMarkdownPreview(textFile && loaded !== null && mode === "preview" && hasPreview(language) ? loaded.text : null); const previewRefused = preview.status === "failed"; const previewable = hasPreview(language) && !previewRefused; const view = previewable && mode === "preview" ? "markdown" : "code"; const codeShown = textFile && loaded !== null && view === "code" && (!htmlPage || source); - // a file's code has no length limit (the load limit bounds it): only a worker that gave up leaves it plain - const code = useHighlightedLines(codeShown ? loaded.text : "", codeShown ? language : null); - const shownPath = info?.path ?? path; + // the source as shown (a cut prefix without the row after its final newline): past + // VIRTUAL_SOURCE_LINES it is the virtualizer's rows, colored in its worker while small enough + // (virtualHighlightable), else plain with the notice; below, CodeLines + const sourceText = useMemo(() => codeShown ? shownSource(normalizeCode(loaded.text), loaded.truncated) : "", [codeShown, loaded]); + const sourceLines = useMemo(() => countLines(sourceText), [sourceText]); + const layout = sourceLayout(sourceLines); + const colorable = canHighlight(language); + const virtual = useMemo(() => codeShown && layout === "virtual" ? { text: sourceText, colored: colorable && virtualHighlightable(sourceText.length, sourceLines) } : null, [codeShown, layout, sourceText, colorable, sourceLines]); + // CodeLines' code has no length limit of its own (the layout bounds it): only a worker that gave up leaves it plain + const code = useHighlightedLines(codeShown && layout === "elements" ? sourceText : "", codeShown ? language : null); + const shownPath = info?.path ?? target.path; const { stem, extension } = pathParts(info?.name ?? shownPath); const { folder } = pathParts(shownPath); // what holds for the whole file is said with its size, where it is seen first, not after a megabyte of text const cutShort = textFile && (!htmlPage || source) && loaded !== null && loaded.truncated; + const budget = textFile ? readBudget(language, info.size) : null; + // a cut at the byte budget is told in sizes; one at the line cap in lines; one by the deadline + // (a slow link) only says part is shown, since its size is not the budget's + const cutBytes = cutShort && budget !== null && loaded.truncatedBy !== "lines" && loaded.truncatedBy !== "time" && (loaded.total ?? info.size) > budget.bytes ? { shown: budget.bytes, total: loaded.total ?? info.size } : null; + // a cut prefix's lines are the reader's count of complete lines: the line just past the cap is unseen + const request = useMemo(() => codeShown ? lineRequest(target.line, sourceText, loaded.truncated, loaded.lines) : null, [codeShown, target.line, sourceText, loaded]); + const requestNotice = (() => { + if (request === null) return null; + switch (request.kind) { + case "shown": return null; + case "past-preview": return t("Line {line} is past the part of this file shown here. Download it to read the rest.", { line: request.line }); + case "past-end": return t("This file has {count} lines; there is no line {line}.", { count: request.lines, line: request.line }); + } + })(); const notes = [ - cutShort && ((loaded.total ?? info.size) > TEXT_PREVIEW_BYTES - ? t("Showing the first {shown} of {total}.", { shown: formatBytes(TEXT_PREVIEW_BYTES), total: formatBytes(loaded.total ?? info.size) }) - : t("Only part of this file is shown. Download it to read the rest.")), + requestNotice, + cutShort && (loaded.truncatedBy === "lines" + ? t("Showing the first {count} lines of this file. Download it to read the rest.", { count: SOURCE_PREVIEW_LINES.toLocaleString(locale) }) + : cutBytes !== null + ? t("Showing the first {shown} of {total}.", { shown: formatBytes(cutBytes.shown), total: formatBytes(cutBytes.total) }) + : t("Only part of this file is shown. Download it to read the rest.")), textFile && loaded !== null && previewRefused && t("Too long to preview"), - codeShown && code.tooLong && t("Too long to highlight"), - // past it the code is one text (CodeLines), and line numbers are an element per line - codeShown && code.lines.length > LINE_ELEMENT_LIMIT && t("Too many lines to number"), + codeShown && (code.tooLong || (virtual !== null && colorable && !virtual.colored)) && t("Too long to highlight"), ].filter((note): note is string => typeof note === "string"); const noticed = notes.length > 0; const copyable = textFile && loaded !== null && !loaded.truncated; const markdown = info?.kind === "text" && /\.(?:md|markdown)$/i.test(info.name); // a page of just the document, not the app over it (FileReader) - const previewUrl = readerHref(machineId, shownPath); + const previewUrl = readerHref(machineId, shownPath + (target.line === null ? "" : `#L${target.line}`)); // images of a markdown file load through the file endpoint, as the file itself does const imagePreview = useCallback((src: string) => markdownImagePreview( src, shownPath, (target) => fetchFileInfo(target, paneId), (target, download) => fileUrl(target, paneId, download), @@ -235,8 +281,8 @@ export function FileViewer({ path: asked, paneId, onClose, onOpen, keyboardActiv if (directory !== null) return ; if (error !== null) return

    {error === "missing" ? t("No readable file at this path.") : t("The file could not be opened.")}

    ; if (candidates !== null) return
    -

    Several files are named {path.split("/").pop()}:

    -
      {candidates.map((candidate) =>
    • )}
    +

    Several files are named {target.path.split("/").pop()}:

    +
      {candidates.map((candidate) =>
    • )}
    ; if (info === null) return

    {t("Opening…")}

    ; switch (info.kind) { @@ -267,7 +313,7 @@ export function FileViewer({ path: asked, paneId, onClose, onOpen, keyboardActiv
    :

    {t("The file could not be opened.")}

    }> - + } ; default: @@ -288,7 +334,7 @@ export function FileViewer({ path: asked, paneId, onClose, onOpen, keyboardActiv {info && (noticed ? : {formatBytes(info.size)})} @@ -312,18 +358,21 @@ export function FileViewer({ path: asked, paneId, onClose, onOpen, keyboardActiv {!page && } ; + // a virtualized source fills the viewer: its rows are a window the user scrolls, so the body + // takes a definite height for the scroll root to fill, instead of growing with the text + const bodyClass = virtual !== null && view === "code" ? "file-viewer-body file-viewer-body-virtual" : "file-viewer-body"; if (page) return (
    {header} -
    {body}
    +
    {body}
    ); return (
    event.target === event.currentTarget && onClose()}> -
    +
    {header} -
    {body}
    +
    {body}
    ); diff --git a/src/components/FilesDialog.css b/src/components/FilesDialog.css new file mode 100644 index 000000000..ce946d058 --- /dev/null +++ b/src/components/FilesDialog.css @@ -0,0 +1,9 @@ +/* Browse | Changes in the Files dialog's header, between the title and Close */ +.files-dialog-tabs { + flex: none; +} + +/* the dialog keeps one height across its two tabs: a short list of changes does not shrink it */ +.files-dialog .modal-body { + min-height: calc(var(--control-h) * 8); +} diff --git a/src/components/FilesDialog.tsx b/src/components/FilesDialog.tsx index 43600fd40..5ebd2d88c 100644 --- a/src/components/FilesDialog.tsx +++ b/src/components/FilesDialog.tsx @@ -1,25 +1,41 @@ -import { useEffect } from "react"; -import { X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { FolderSearch, X } from "lucide-react"; +import "./FilesDialog.css"; import { DirectoryBrowser } from "./DirectoryBrowser.tsx"; +import { RepoChanges } from "./RepoChanges.tsx"; +import type { RepoDiffFile } from "../../shared/protocol.ts"; import { useT } from "../lib/i18n.ts"; +import type { RepoComparison } from "../lib/repoDiff.ts"; import { nativeModalOver, useFocusTrap } from "../lib/useFocusTrap.ts"; import { useSheetSwipe } from "../lib/sheets.ts"; import { usePhoneSheetBack } from "../lib/sheetHistory.ts"; +import { useMediaQuery } from "../lib/useMediaQuery.ts"; export interface FilesDialogProps { /** the folder to open at: the pane's own */ start: string; - /** a file is open above this dialog: Escape belongs to the viewer, which closes first */ + /** the pane whose repository's changes are listed */ + paneId: string; + /** a file or a diff is open above this dialog: Escape belongs to it, which closes first */ viewing: boolean; onOpenFile: (path: string) => void; + onOpenDiff: (comparison: RepoComparison, file: RepoDiffFile) => void; + /** the command palette, already browsing this folder (it opens above this dialog) */ + onBrowseFolders: (path: string) => void; onClose: () => void; } -/** The files of the pane's folder (and any other), each opened in the file viewer. */ -export function FilesDialog({ start, viewing, onOpenFile, onClose }: FilesDialogProps) { +type Tab = "browse" | "changes"; + +/** The files of the pane's folder (and any other), each opened in the file viewer, and the changes of its repository, each opened as a diff. */ +export function FilesDialog({ start, paneId, viewing, onOpenFile, onOpenDiff, onBrowseFolders, onClose }: FilesDialogProps) { const t = useT(); + const phone = useMediaQuery("(max-width: 480px)"); const surface = useFocusTrap(true); + const [tab, setTab] = useState("browse"); + // the changes are read with the first visit and kept, as the browser keeps its folder + const [changesVisited, setChangesVisited] = useState(false); useSheetSwipe(surface, onClose); // a file opened from the list is drawn over it with an entry of its own: Back closes the file first usePhoneSheetBack(onClose, !viewing); @@ -30,15 +46,28 @@ export function FilesDialog({ start, viewing, onOpenFile, onClose }: FilesDialog window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose, viewing]); + const show = (next: Tab): void => { + setTab(next); + if (next === "changes") setChangesVisited(true); + }; + // the folder the browser shows now (it tells each one it opens), else the one the dialog opened at + const folder = useRef(null); + const browseFolders = (): void => onBrowseFolders(folder.current ?? (start || "~")); return (
    event.target === event.currentTarget && onClose()}>

    {t("Files")}

    +
    + + +
    + {!phone && }
    - + + {changesVisited && }
    diff --git a/src/components/HighlightedCode.css b/src/components/HighlightedCode.css index 2804eb669..7d8c1b743 100644 --- a/src/components/HighlightedCode.css +++ b/src/components/HighlightedCode.css @@ -47,6 +47,8 @@ } .hl-keyword { color: var(--syntax-keyword); } +.hl-line.is-marked { background: var(--accent-tint); } +.hl-code[data-line-numbers] .hl-line.is-marked::before { color: var(--accent); } .hl-string { color: var(--syntax-string); } .hl-number { color: var(--syntax-number); } .hl-comment { color: var(--syntax-comment); } diff --git a/src/components/HighlightedCode.tsx b/src/components/HighlightedCode.tsx index db6bd7217..d22e2ed0f 100644 --- a/src/components/HighlightedCode.tsx +++ b/src/components/HighlightedCode.tsx @@ -4,7 +4,6 @@ import { canHighlight, countLines, extendLines, LINE_ELEMENT_LIMIT, normalizeCod import { highlightedFrom, highlightKnown, highlightOffThread, type Highlighted } from "../lib/highlightOffThread.ts"; import { useT } from "../lib/i18n.ts"; import { linkFilePaths, linksFilePaths } from "./FilePathLink.tsx"; -import { useSettings } from "../lib/settings.ts"; import "./HighlightedCode.css"; /** @@ -12,13 +11,12 @@ import "./HighlightedCode.css"; * never holds the page: short code is highlighted at once, longer code in a worker (shown plain * until it answers, or with the colors it had while a reply grows), and code the worker gives up on * stays plain. Above `limit` characters, or `LINE_ELEMENT_LIMIT` lines, it is not tried at all; - * otherwise the worker's budget bounds it. Off in Settings, or in a language it has no grammar for, - * every code is plain and never too long. + * otherwise the worker's budget bounds it. In a language it has no grammar for, every code is plain + * and never too long. */ export function useHighlightedLines(code: string, language: string | null, limit?: number): Highlighted { - const { settings } = useSettings(); const source = useMemo(() => normalizeCode(code), [code]); - const highlighted = settings.highlightCode && canHighlight(language) ? language : null; + const highlighted = canHighlight(language) ? language : null; const manyLines = useMemo(() => countLines(source) > LINE_ELEMENT_LIMIT, [source]); const overLimit = highlighted !== null && ((limit !== undefined && code.length > limit) || manyLines); // what is known without waiting: no language, too long, short enough, or highlighted before @@ -58,6 +56,8 @@ interface CodeLinesProps { lineNumbers?: boolean; wrap?: boolean; className?: string; + /** A requested source line; the host has checked that it is among the numbered lines. */ + markedLine?: number | null; /** opens a file path the code names; null leaves every path as text */ open: ((path: string) => void) | null; } @@ -74,7 +74,7 @@ interface CodeLinesProps { * Past `LINE_ELEMENT_LIMIT` lines it is one text, as drawing an element per line would hold the page. * The ref is that `
    `: the file viewer selects it when the clipboard is out of reach.
      */
    -export const CodeLines = memo(forwardRef(function CodeLines({ lines, open, lineNumbers = false, wrap = false, className }, ref) {
    +export const CodeLines = memo(forwardRef(function CodeLines({ lines, open, lineNumbers = false, wrap = false, className, markedLine = null }, ref) {
       // one text past LINE_ELEMENT_LIMIT lines, with no numbers then: they are an element per line
       const plain = lines.length > LINE_ELEMENT_LIMIT;
       // index keys: the lines are a static list that is rebuilt as a whole
    @@ -83,11 +83,11 @@ export const CodeLines = memo(forwardRef(functio
         : lines.map((tokens, index) => (
           
             {index > 0 && "\n"}
    -        
    +        
               {tokens.map((token, n) => token.role === null ? {linkFilePaths(token.text, open)} : {linkFilePaths(token.text, open)})}
             
           
    -    )), [lines, plain, open]);
    +    )), [lines, plain, open, markedLine]);
       const numbered = lineNumbers && !plain;
       // the gutter is as wide as the last line's number, at least two digits
       const gutter = numbered ? { "--hl-digits": String(Math.max(2, String(lines.length).length)) } as CSSProperties : undefined;
    diff --git a/src/components/ImageLightbox.css b/src/components/ImageLightbox.css
    new file mode 100644
    index 000000000..b9026d7d2
    --- /dev/null
    +++ b/src/components/ImageLightbox.css
    @@ -0,0 +1,149 @@
    +/* the chat lies under every dialog, so the picture takes the modal layer itself; the page behind
    +   is hidden, as it is behind a diagram (MermaidLightbox.css) */
    +.image-lightbox-scrim {
    +  padding: 0;
    +  background: var(--bg);
    +}
    +
    +.image-lightbox {
    +  position: relative;
    +  width: 100%;
    +  height: 100%;
    +  background: var(--bg);
    +  overflow: hidden;
    +}
    +
    +.image-lightbox-bar,
    +.image-lightbox-foot {
    +  position: absolute;
    +  z-index: 1;
    +  display: flex;
    +  align-items: center;
    +  gap: var(--space-1);
    +  padding: var(--space-1);
    +  background: var(--bg-elevated);
    +  border: var(--hairline) solid var(--border);
    +  border-radius: var(--radius-lg);
    +  box-shadow: var(--shadow-pop);
    +}
    +
    +/* clear of a notch or a rounded corner: the page is laid out with viewport-fit=cover */
    +.image-lightbox-bar {
    +  top: calc(var(--space-3) + env(safe-area-inset-top, 0px));
    +  right: calc(var(--space-3) + env(safe-area-inset-right, 0px));
    +}
    +
    +.image-lightbox-zoom {
    +  min-width: 4ch;
    +  text-align: center;
    +  color: var(--text-dim);
    +  font-family: var(--font-mono);
    +  font-size: var(--fs-xs);
    +  font-variant-numeric: tabular-nums;
    +}
    +
    +.image-lightbox-bar .icon-button.is-copied {
    +  color: var(--status-done);
    +}
    +
    +/* the stage never scrolls: the picture moves inside it, and the page behind stays put */
    +.image-lightbox-stage {
    +  position: absolute;
    +  inset: 0;
    +  overflow: hidden;
    +  cursor: grab;
    +  touch-action: none;
    +  user-select: none;
    +}
    +
    +.image-lightbox-stage.is-dragging { cursor: grabbing; }
    +
    +/* the canvas is the stage's own box, so a zoom about a stage point is exact; the picture sits
    +   fitted at its centre, clear of the bars */
    +.image-lightbox-canvas {
    +  position: absolute;
    +  inset: 0;
    +  display: flex;
    +  align-items: center;
    +  justify-content: center;
    +  transform-origin: 0 0;
    +}
    +
    +.image-lightbox-canvas img {
    +  display: block;
    +  width: auto;
    +  height: auto;
    +  max-width: calc(100% - 2 * var(--space-4));
    +  max-height: calc(100% - 2 * (var(--control-h) + 2 * var(--space-3)));
    +}
    +
    +.image-lightbox-failed {
    +  position: absolute;
    +  inset: 0;
    +  display: flex;
    +  flex-direction: column;
    +  align-items: center;
    +  justify-content: center;
    +  gap: var(--space-2);
    +  margin: 0;
    +  color: var(--text-dim);
    +  font-size: var(--fs-sm);
    +}
    +
    +.image-lightbox-failed svg {
    +  width: var(--space-6);
    +  height: var(--space-6);
    +}
    +
    +/* the caption and the gallery's steps, centred on the bottom edge, above a phone's home bar */
    +.image-lightbox-foot {
    +  bottom: calc(var(--space-3) + env(safe-area-inset-bottom, 0px));
    +  left: calc(var(--space-3) + env(safe-area-inset-left, 0px));
    +  right: calc(var(--space-3) + env(safe-area-inset-right, 0px));
    +  width: fit-content;
    +  max-width: calc(100% - 2 * var(--space-3) - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
    +  margin: 0 auto;
    +  flex-direction: column;
    +  gap: var(--space-1);
    +}
    +
    +.image-lightbox-foot-row {
    +  display: flex;
    +  align-items: center;
    +  gap: var(--space-1);
    +  min-width: 0;
    +  max-width: 100%;
    +}
    +
    +.image-lightbox-caption {
    +  display: flex;
    +  align-items: baseline;
    +  gap: var(--space-2);
    +  min-width: 0;
    +  margin: 0;
    +  padding: 0 var(--space-2);
    +  color: var(--text);
    +  font-size: var(--fs-sm);
    +}
    +
    +.image-lightbox-name {
    +  min-width: 0;
    +  overflow: hidden;
    +  text-overflow: ellipsis;
    +  white-space: nowrap;
    +}
    +
    +.image-lightbox-count {
    +  flex: 0 0 auto;
    +  color: var(--text-dim);
    +  font-size: var(--fs-xs);
    +  font-variant-numeric: tabular-nums;
    +}
    +
    +.image-lightbox-note {
    +  margin: 0;
    +  padding: 0 var(--space-2) var(--space-1);
    +  color: var(--status-blocked);
    +  font-size: var(--fs-xs);
    +  text-align: center;
    +}
    diff --git a/src/components/ImageLightbox.tsx b/src/components/ImageLightbox.tsx
    new file mode 100644
    index 000000000..00068f37d
    --- /dev/null
    +++ b/src/components/ImageLightbox.tsx
    @@ -0,0 +1,210 @@
    +/**
    + * A chat picture over the whole screen, fitted to it, the message's other pictures a step away.
    + * The wheel or a pinch zooms at the pointer, a drag pans, a double-click toggles fit and the
    + * picture's own pixels, and the bar and the keys (+ - 0, arrows) do the same; at fit, Left and
    + * Right step through the gallery. Copy puts the picture on the clipboard as PNG and Download
    + * saves it under its name. Escape, the close button, a phone's Back and a swipe down that starts
    + * on the bar or the caption leave (the stage takes every stroke for panning), and the focus
    + * returns to the thumbnail that opened it while it is still drawn. ChatView holds the viewer, not
    + * the chat row, so a row scrolled out of the render window does not close it. The zoom arithmetic
    + * is MermaidLightbox's.
    + */
    +import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from "react";
    +import { createPortal } from "react-dom";
    +import { Check, ChevronLeft, ChevronRight, Copy, Download, ImageOff, Minus, Plus, Scan, X } from "lucide-react";
    +
    +import "./ImageLightbox.css";
    +
    +import { canCopyImage, copyImage } from "../lib/clipboard.ts";
    +import { useT } from "../lib/i18n.ts";
    +import { actualSizeFactor, arrowAction, wrapIndex, zoomPercent, type GalleryImage } from "../lib/imageViewer.ts";
    +import { usePhoneSheetBack } from "../lib/sheetHistory.ts";
    +import { useSheetSwipe } from "../lib/sheets.ts";
    +import { nativeModalOver, useFocusTrap } from "../lib/useFocusTrap.ts";
    +import { FIT, MAX_ZOOM, MIN_ZOOM, PAN_STEP, STEP, zoomAt, type View } from "./MermaidLightbox.tsx";
    +
    +export function ImageLightbox({ images, index, onClose }: { images: GalleryImage[]; index: number; onClose: () => void }) {
    +  const t = useT();
    +  const close = useRef(null);
    +  const surface = useFocusTrap(true, { initialFocus: close });
    +  usePhoneSheetBack(onClose);
    +  useSheetSwipe(surface, onClose);
    +  const stage = useRef(null);
    +  const picture = useRef(null);
    +  const [current, setCurrent] = useState(() => wrapIndex(index, images.length));
    +  const [view, setView] = useState(FIT);
    +  const latest = useRef(view);
    +  latest.current = view;
    +  // the fitted picture's size over its natural size, so the bar says the zoom in its own pixels
    +  const [fit, setFit] = useState(1);
    +  const [failed, setFailed] = useState(null);
    +  const [copied, setCopied] = useState<"done" | "failed" | null>(null);
    +  const [dragging, setDragging] = useState(false);
    +  const pointers = useRef(new Map());
    +  const count = images.length;
    +  const image = images[current];
    +  const many = count > 1;
    +  const copyable = canCopyImage();
    +
    +  const measure = (): void => {
    +    const node = picture.current;
    +    if (node && node.naturalWidth > 0 && node.offsetWidth > 0) setFit(node.offsetWidth / node.naturalWidth);
    +  };
    +  useEffect(() => {
    +    const node = stage.current;
    +    if (!node || typeof ResizeObserver === "undefined") return;
    +    const observer = new ResizeObserver(measure);
    +    observer.observe(node);
    +    return () => observer.disconnect();
    +  }, []);
    +
    +  const center = (): { x: number; y: number } => {
    +    const box = stage.current?.getBoundingClientRect();
    +    return { x: (box?.width ?? 0) / 2, y: (box?.height ?? 0) / 2 };
    +  };
    +  const zoomBy = (factor: number): void => { const c = center(); setView((v) => zoomAt(v, factor, c.x, c.y)); };
    +  const step = (by: -1 | 1): void => {
    +    setCurrent((at) => wrapIndex(at + by, count));
    +    setView(FIT);
    +    setFit(1);
    +    setCopied(null);
    +  };
    +
    +  useEffect(() => {
    +    const onKey = (event: KeyboardEvent): void => {
    +      const node = surface.current;
    +      if (!node || nativeModalOver(node)) return;
    +      // a dialog opened over this one by its shortcut (Settings) has the keys; the focus may
    +      // also rest on the body (Safari after a click on the stage), which is still ours
    +      const dialog = event.target instanceof Element ? event.target.closest('[aria-modal="true"]') : null;
    +      if (dialog !== null && dialog !== node) return;
    +      if (event.ctrlKey || event.metaKey || event.altKey) return;
    +      switch (event.key) {
    +        case "Escape": onClose(); break;
    +        case "+": case "=": zoomBy(STEP); break;
    +        case "-": case "_": zoomBy(1 / STEP); break;
    +        case "0": setView(FIT); break;
    +        default: {
    +          const action = arrowAction(event.key, latest.current.scale > 1, count, PAN_STEP);
    +          if (action === null) return;
    +          if (action.kind === "pan") setView((v) => ({ ...v, x: v.x + action.dx, y: v.y + action.dy }));
    +          else step(action.by);
    +        }
    +      }
    +      event.stopPropagation();
    +      event.preventDefault();
    +    };
    +    window.addEventListener("keydown", onKey, true);
    +    return () => window.removeEventListener("keydown", onKey, true);
    +  }, [onClose, count]);
    +
    +  // React's onWheel is passive and could not stop the page behind from scrolling
    +  useEffect(() => {
    +    const node = stage.current;
    +    if (!node) return;
    +    const onWheel = (event: WheelEvent): void => {
    +      event.preventDefault();
    +      const box = node.getBoundingClientRect();
    +      // a trackpad pinch (ctrl) sends small deltas; a mouse wheel notch is a fixed step
    +      const factor = Math.exp(-event.deltaY * (event.ctrlKey ? 0.01 : 0.0015));
    +      setView((v) => zoomAt(v, factor, event.clientX - box.left, event.clientY - box.top));
    +    };
    +    node.addEventListener("wheel", onWheel, { passive: false });
    +    return () => node.removeEventListener("wheel", onWheel);
    +  }, []);
    +
    +  const down = (event: ReactPointerEvent): void => {
    +    event.currentTarget.setPointerCapture(event.pointerId);
    +    pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
    +    setDragging(true);
    +  };
    +  const move = (event: ReactPointerEvent): void => {
    +    const held = pointers.current;
    +    const before = held.get(event.pointerId);
    +    if (!before) return;
    +    const after = { x: event.clientX, y: event.clientY };
    +    const box = event.currentTarget.getBoundingClientRect();
    +    if (held.size === 2) {
    +      const other = [...held.entries()].find(([id]) => id !== event.pointerId)![1];
    +      const was = Math.hypot(before.x - other.x, before.y - other.y);
    +      const now = Math.hypot(after.x - other.x, after.y - other.y);
    +      // the pinch zooms about the midpoint and carries it along as it moves
    +      const mid = { x: (after.x + other.x) / 2 - box.left, y: (after.y + other.y) / 2 - box.top };
    +      const shift = { x: (after.x - before.x) / 2, y: (after.y - before.y) / 2 };
    +      if (was > 0) setView((v) => { const z = zoomAt(v, now / was, mid.x, mid.y); return { ...z, x: z.x + shift.x, y: z.y + shift.y }; });
    +    } else {
    +      setView((v) => ({ ...v, x: v.x + after.x - before.x, y: v.y + after.y - before.y }));
    +    }
    +    held.set(event.pointerId, after);
    +  };
    +  const up = (event: ReactPointerEvent): void => {
    +    pointers.current.delete(event.pointerId);
    +    if (pointers.current.size === 0) setDragging(false);
    +  };
    +  const toggle = (event: ReactMouseEvent): void => {
    +    const box = event.currentTarget.getBoundingClientRect();
    +    setView((v) => v.scale === 1 && v.x === 0 && v.y === 0 ? zoomAt(v, actualSizeFactor(fit), event.clientX - box.left, event.clientY - box.top) : FIT);
    +  };
    +
    +  const copy = async (): Promise => {
    +    const node = picture.current;
    +    if (!node) return;
    +    const ok = await copyImage(node);
    +    setCopied(ok ? "done" : "failed");
    +    if (ok) window.setTimeout(() => setCopied((state) => state === "done" ? null : state), 1500);
    +  };
    +
    +  if (image === undefined) return null;
    +  const unavailable = failed === image.src;
    +  return createPortal(
    +    
    +
    +
    + + {zoomPercent(view.scale, fit)}% + + + {copyable && } + + +
    +
    +
    + {!unavailable && {image.name} setFailed(image.src)} + />} +
    + {unavailable &&

    } +
    +
    +
    + {many && } +

    + {image.name} + {many && {t("{n} of {count}", { n: current + 1, count })}} +

    + {many && } +
    + {copied === "failed" &&

    {t("Couldn't copy the image. Download it instead.")}

    } +
    +
    +
    , + document.body, + ); +} diff --git a/src/components/KeyBar.css b/src/components/KeyBar.css index eb579efc5..14fb48279 100644 --- a/src/components/KeyBar.css +++ b/src/components/KeyBar.css @@ -11,8 +11,9 @@ flex: none; align-items: center; gap: var(--space-1); - height: calc(var(--keybar-h) + env(safe-area-inset-bottom, 0px)); - padding: 0 0 env(safe-area-inset-bottom, 0px); + height: var(--keybar-h); + /* the keys stand off the pane's edges rather than against its border */ + padding: 0 var(--space-2); border-top: var(--hairline) solid var(--border); background: var(--bg-panel); overflow-x: auto; @@ -25,6 +26,14 @@ display: none; } +/* the bar at the bottom of the screen (KeyBar.tsx) keeps clear of the home indicator, though + less than the whole safe area: the bar's own padding around its keys is part of that room, + as the composer's is (Composer.css) */ +.key-bar.is-at-screen-bottom { + height: calc(var(--keybar-h) + max(0px, env(safe-area-inset-bottom, 0px) - var(--space-2))); + padding-bottom: max(0px, calc(env(safe-area-inset-bottom, 0px) - var(--space-2))); +} + .key { display: inline-flex; align-items: center; diff --git a/src/components/KeyBar.tsx b/src/components/KeyBar.tsx index f6588b570..76bb75dc1 100644 --- a/src/components/KeyBar.tsx +++ b/src/components/KeyBar.tsx @@ -22,6 +22,8 @@ export interface KeyBarProps { /** on a touch screen: whether the keyboard types straight into the terminal (else the input line) */ directTyping?: boolean; onToggleDirect?: () => void; + /** greys out only the direct-typing toggle (an input-line composition is in progress) */ + directDisabled?: boolean; } /** @@ -173,13 +175,31 @@ export const EXTRA_KEY_CAPS: Partial(null); + useLayoutEffect(() => { + const bar = barRef.current; + if (!bar) return; + const place = (): void => { + // measured without the room the class adds, which would otherwise keep itself + bar.classList.remove("is-at-screen-bottom"); + bar.classList.toggle("is-at-screen-bottom", bar.getBoundingClientRect().bottom >= window.innerHeight - 1); + }; + place(); + // the pane's own frame and what holds it change size on a split or zoom with the page unchanged + const observer = new ResizeObserver(place); + for (let node: HTMLElement | null = bar.parentElement; node; node = node.parentElement) observer.observe(node); + window.addEventListener("resize", place); + return () => { observer.disconnect(); window.removeEventListener("resize", place); }; + }, []); return ( -
    +
    {/* first: on a narrow cover screen the row scrolls, and the mode toggle must not be the key cut off */} {onToggleDirect && ( - + )} diff --git a/src/components/MachineSidebar.tsx b/src/components/MachineSidebar.tsx index b5a34d68a..75cc7ef37 100644 --- a/src/components/MachineSidebar.tsx +++ b/src/components/MachineSidebar.tsx @@ -28,8 +28,8 @@ export function MachineSidebar(props: Props) { const t = useT(); const { canInstall, installed, install, help } = useInstallPrompt(); const [installHelpOpen, setInstallHelpOpen] = useState(false); - // both lists draw from it: live counters per PC and the finishes looked at here (lib/sidebarActivity.tsx) - const activity = useSidebarActivityState(props.machines, props.selectedMachineId, props.selectedPaneId); + // both lists draw from it: live counters per PC (lib/sidebarActivity.tsx) + const activity = useSidebarActivityState(props.machines); // no top bar: a workspace starts from its PC's header, and Add PC lives in Settings → Remote PCs return
    @@ -66,8 +66,13 @@ function MachineGroup({ machine, ...props }: Props & { machine: Machine }) { if (inFlight.current) return; inFlight.current = true; setBusy(true); - try { await machineRequest(`/${machine.id}`, method, body); setError(null); if (method === "DELETE" && props.selectedMachineId === machine.id) props.onSelect("local", null); } - catch (e) { setError(e instanceof Error ? e.message : String(e)); } + // The selected PC moves to this one before it is removed: the server tells every tab the + // roster without it before it answers, and a selection on a PC the roster has dropped reads + // as one removed elsewhere ("That link's PC is not set up here"). A failure puts it back. + const leaving = method === "DELETE" && props.selectedMachineId === machine.id ? { pane: props.selectedPaneId } : null; + if (leaving) props.onSelect("local", null); + try { await machineRequest(`/${machine.id}`, method, body); setError(null); } + catch (e) { setError(e instanceof Error ? e.message : String(e)); if (leaving) props.onSelect(machine.id, leaving.pane); } finally { inFlight.current = false; setBusy(false); } }; const actions: AppActions = { ...props.actions, selectPane: (id) => props.onSelect(machine.id, id), openNewSession: () => props.onNew(machine.id) }; @@ -107,7 +112,7 @@ function MachineGroup({ machine, ...props }: Props & { machine: Machine }) { {confirmDelete &&

    {t("Removes this registration. Remote sessions keep running.")}

    }
    } {error &&

    {error}

    } - {!collapsed &&
    + {!collapsed &&
    {!online && !machine.snapshot ?

    {t("No saved sessions")}

    : }
    } ; diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 38f7ba6bc..1c78c1bea 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -1,5 +1,5 @@ -import { createContext, memo, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { Check, Copy, Maximize2 } from "lucide-react"; +import { createContext, Fragment, memo, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { BookOpen, Check, Copy, ImageOff, Maximize2 } from "lucide-react"; import { foldCode, mathNestsTooDeep, parseMarkdown, type InlineNode, type ListBlock, type MarkdownBlock } from "../lib/markdown.ts"; import { codeIsFilePath, OpenFileContext, splitFilePaths } from "../lib/filePaths.ts"; @@ -9,22 +9,27 @@ import { CHAT_HIGHLIGHT_LIMIT, languageForFence } from "../lib/highlight.ts"; import { HighlightedCode } from "./HighlightedCode.tsx"; import { useT } from "../lib/i18n.ts"; import { type MarkdownImagePreview } from "../lib/markdownImages.ts"; +import { imageSlotStyle, standaloneImage } from "../lib/imageBox.ts"; import { formatBytes } from "../lib/bridgeProgress.ts"; import { MermaidLightbox } from "./MermaidLightbox.tsx"; import { copyText } from "../lib/clipboard.ts"; +import { lazyChunk } from "../lib/lazyChunk.ts"; +import type { SkillMatcher } from "../lib/skillMentions.ts"; +import { previewChunks } from "../lib/textPreview.ts"; + +/** The pane's skill names: a name in an answer's plain text, in its trigger form, reads as a chip. */ +const SkillMatcherContext = createContext(null); type Katex = typeof import("katex").default; /** KaTeX is a fifth of the app's script: the first expression loads it (lib/katex.ts), and every later one has it at once. */ let katexModule: Katex | null = null; -let katexLoad: Promise | null = null; -/** KaTeX, fetched once; a static render (the unit test) waits on it to draw math as the chat does. */ -export function loadKatex(): Promise { - // offline before it was ever fetched: every expression stays in its source form until a reload - // (no retry: Chrome keeps a failed import's answer for the page's life without fetching again) - katexLoad ??= import("../lib/katex.ts").then((module) => (katexModule = module.default)); - return katexLoad; -} +/** + * KaTeX, fetched once; a static render (the unit test) waits on it to draw math as the chat does. + * Offline before it was ever fetched, or in a tab older than the server's build, every expression + * stays in its source form and the app offers a reload (lib/lazyChunk.ts). + */ +export const loadKatex: () => Promise = lazyChunk(() => import("../lib/katex.ts").then((module) => (katexModule = module.default))); function useKatex(): Katex | null { const [katex, setKatex] = useState(katexModule); @@ -55,13 +60,9 @@ function MathExpression({ value, displayMode = false }: { value: string; display } type MermaidRender = typeof import("../lib/mermaid.ts").renderMermaid; -let mermaidLoad: Promise | null = null; -/** Mermaid, fetched once with the first diagram (lib/mermaid.ts); offline before that, a diagram stays a code block. */ -function loadMermaid(): Promise { - mermaidLoad ??= import("../lib/mermaid.ts").then((module) => module.renderMermaid); - return mermaidLoad; -} +/** Mermaid, fetched once with the first diagram (lib/mermaid.ts); one that cannot load leaves diagrams as code blocks until a reload. */ +const loadMermaid: () => Promise = lazyChunk(() => import("../lib/mermaid.ts").then((module) => module.renderMermaid)); /** Whether the page is drawn dark (`data-theme` on , which Settings keeps current). */ function useDarkTheme(): boolean { @@ -108,7 +109,41 @@ function MermaidDiagram({ value }: { value: string }) { type MarkdownImageResolver = (src: string) => Promise; export const MarkdownImageContext = createContext(null); -function MarkdownImage({ src, alt, width, fallback, resolve, interactive }: { src: string; alt: string; width?: number; fallback: ReactNode; resolve: MarkdownImageResolver; interactive: boolean }) { +/** + * An image that holds its box until it decodes (lib/imageBox.ts). With `slot`, the wrapper is the + * box while the bytes arrive and after a failure, where a note stands in for the picture, so the + * row around it moves at most once, when the natural size is in; ChatView.css draws a standalone + * Markdown image's 16:9 frame and a chat thumbnail's fixed box. Decoded, the wrapper gives its box + * up (`display: contents`) and the one image renders bare, nothing remounted: its box, hit area + * and alignment are its own. Without `slot` (a badge in a sentence) nothing is held and a failure + * shows `fallback`. `src` is null while an address is still being resolved: the slot stands from + * the first paint. + */ +export function ReservedImage({ src, alt, width, className, slot, slotStyle, fallback }: { + src: string | null; alt: string; width?: number; className?: string; slot: boolean; slotStyle?: CSSProperties; fallback?: ReactNode; +}) { + const t = useT(); + // keyed by address: another one starts from the slot again and never takes the old one's answer + const [settled, setSettled] = useState<{ src: string; failed: boolean } | null>(null); + const phase = src !== null && settled?.src === src ? (settled.failed ? "failed" : "decoded") : "loading"; + // a cached image is complete before `onLoad` can fire + const complete = useCallback((image: HTMLImageElement | null) => { + if (image !== null && src !== null && image.complete && image.naturalWidth > 0) setSettled({ src, failed: false }); + }, [src]); + if (phase === "failed" && !slot) return <>{fallback}; + const image = src === null ? null : {alt} setSettled({ src, failed: false })} onError={() => setSettled({ src, failed: true })} />; + if (!slot) return image; + if (phase === "failed") { + return + + ; + } + if (phase === "loading") return {image}; + return {image}; +} + +function MarkdownImage({ src, alt, width, fallback, resolve, interactive, standalone }: { src: string; alt: string; width?: number; fallback: ReactNode; resolve: MarkdownImageResolver; interactive: boolean; standalone: boolean }) { const t = useT(); const [loaded, setLoaded] = useState<{ src: string; resolve: MarkdownImageResolver; preview: MarkdownImagePreview | null } | null>(null); useEffect(() => { @@ -120,25 +155,36 @@ function MarkdownImage({ src, alt, width, fallback, resolve, interactive }: { sr return () => { live = false; }; }, [src, resolve]); const preview = loaded?.src === src && loaded.resolve === resolve ? loaded.preview : undefined; - if (preview === undefined) return {alt}; if (preview === null) return <>{fallback}; - if (preview.kind === "inline") return {alt}; - const message = t("This image is {size}; download it to view.", { size: formatBytes(preview.size) }); - return interactive ? {message} : {message}; + if (preview?.kind === "too-large") { + const message = t("This image is {size}; download it to view.", { size: formatBytes(preview.size) }); + return interactive ? {message} : {message}; + } + // still being stat'ed: a standalone image's slot stands from the first paint, a badge shows its alt + if (preview === undefined && !standalone) return {alt}; + return ; } -/** `interactive` is false inside a link or file label: nothing clickable nests in another. */ -function Inline({ nodes, interactive = true }: { nodes: InlineNode[]; interactive?: boolean }) { +/** + * `interactive` is false inside a link or file label: nothing clickable nests in another. + * `standalone` says the line is one image and nothing else (lib/imageBox.ts): it holds a slot while it decodes. + */ +function Inline({ nodes, interactive = true, standalone = false }: { nodes: InlineNode[]; interactive?: boolean; standalone?: boolean }) { const context = useContext(OpenFileContext); const open = interactive ? context : null; const t = useT(); const imageResolver = useContext(MarkdownImageContext); + const skillMatcher = useContext(SkillMatcherContext); return <>{nodes.map((node, index) => { const key = `${node.type}-${index}`; switch (node.type) { case "text": - if (open === null) return {node.value}; - return {splitFilePaths(node.value).map((part, n) => typeof part === "string" ? part : )}; + // a skill chip carries its raw text, so a selection copies what the agent wrote; not inside a link or file label + return {(interactive && skillMatcher !== null ? skillMatcher(node.value) : [node.value]).map((piece, p) => { + if (typeof piece !== "string") return ; + if (open === null) return piece; + return {splitFilePaths(piece).map((part, n) => typeof part === "string" ? part : )}; + })}; case "code": { const file = fileUriPath(node.value); if (open !== null && file !== null) return ; @@ -148,14 +194,14 @@ function Inline({ nodes, interactive = true }: { nodes: InlineNode[]; interactiv } case "math": return ; case "image": { - return imageResolver === null + return {imageResolver === null ? - : } />; + : } />}; } case "strong": return ; case "em": return ; case "del": return ; - case "link": return ; + case "link": return ; // the label opens the file; where nothing can open one, the path shows after it, as Codex's terminal does case "file": { const label = ; @@ -225,7 +271,7 @@ function CodeBlock({ language, value }: { language: string; value: string }) { if (node && view && node.getBoundingClientRect().top < view.getBoundingClientRect().top) node.scrollIntoView({ block: "start" }); }, [expanded]); return ( -
    +
    {language || "text"}
    - )} - items={items} - align={align} - onClose={onClose} - /> - ); -} diff --git a/src/components/NewSessionDialog.tsx b/src/components/NewSessionDialog.tsx index d479418d9..254ecdd29 100644 --- a/src/components/NewSessionDialog.tsx +++ b/src/components/NewSessionDialog.tsx @@ -4,6 +4,7 @@ import { FolderOpen, X } from "lucide-react"; import "./NewSessionDialog.css"; import type { AgentKind } from "../../shared/protocol.ts"; +import { MAX_LABEL_CHARS } from "../../shared/labels.ts"; import { ApiError } from "../lib/api.ts"; import { AgentPicker, rememberAgent, rememberedAgent } from "./AgentPicker.tsx"; import { DirectoryBrowser } from "./DirectoryBrowser.tsx"; @@ -215,6 +216,7 @@ export function NewSessionDialog({ open, defaultCwd, tab = null, onClose, onCrea .pane-frame-head { position: absolute; right: 0; top: 0; z-index: var(--z-banner); background: transparent; } .pane-frame.is-single .pane-frame-title { display: none; } -.pane-frame.is-single .pane-frame-menu { opacity: 0; } -.pane-frame.is-single:hover .pane-frame-menu, -.pane-frame.is-single:focus-within .pane-frame-menu { opacity: 1; } -/* Single-pane chrome must not cover the search controls or the history scrollbar. */ -.pane-frame.is-single:has(.pane-terminal.has-viewport-tools) > .pane-frame-head { right: var(--space-3); } +/* A mouse uses right-click; keyboard users reveal the ellipsis by tabbing to it. A screen that + also takes touch keeps it, since a finger has no right-click there. */ +@media (hover: hover) and (pointer: fine) and (not (any-pointer: coarse)) { + .pane-frame-menu { opacity: 0; pointer-events: none; } + .pane-frame-menu:focus-visible { opacity: 1; pointer-events: auto; } +} +/* Leave room beside the floating pane menu for chat Find. Touch/narrow layouts + put pane actions in the tab strip instead, so no extra inset is needed there. */ +.pane-column:not(:has(.tab-strip-menu)) .pane-frame.is-single { + --chat-find-menu-space: calc(var(--control-h) + var(--space-2)); +} +/* Single-pane chrome must not cover the search controls. */ .pane-frame.is-single:has(.find-bar) > .pane-frame-head { display: none; } /* the tab strip's touch menu carries the open pane's actions: a lone pane draws no second ellipsis */ .pane-column:has(.tab-strip-menu) .pane-frame.is-single > .pane-frame-head { display: none; } @@ -62,8 +69,6 @@ .pane-canvas-error { position: absolute; z-index: var(--z-modal); top: var(--space-2); right: var(--space-2); max-width: min(30rem, 90%); padding: var(--space-2); background: var(--bg-elevated); color: var(--status-blocked); border: var(--hairline) solid var(--border); } .pane-rename-form { display: flex; flex-direction: column; gap: var(--space-3); } @media (pointer: coarse) { - .pane-frame.is-single:has(.pane-terminal.has-viewport-tools) > .pane-frame-head { right: var(--space-6); } - .pane-frame.is-single .pane-frame-menu { opacity: 1; } .pane-frame-head { height: var(--touch-target); } .pane-frame-menu { min-width: var(--touch-target); height: var(--touch-target); } } diff --git a/src/components/PaneCanvas.tsx b/src/components/PaneCanvas.tsx index e8c5a420c..4a672f90f 100644 --- a/src/components/PaneCanvas.tsx +++ b/src/components/PaneCanvas.tsx @@ -1,11 +1,11 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type MouseEvent, type MutableRefObject, type PointerEvent, type ReactNode } from "react"; -import { Columns2, Ellipsis, Eraser, Maximize2, MousePointer2, Pencil, Rows2, ArrowLeftRight, X } from "lucide-react"; +import { Columns2, Ellipsis, Eraser, Maximize2, Pencil, Rows2, ArrowLeftRight, X } from "lucide-react"; import { createPortal } from "react-dom"; import type { HerdrPane, PaneLayoutSnapshot, SessionSnapshot } from "../../shared/protocol.ts"; import { useMachineApi } from "../lib/machineContext.tsx"; import { PaneSubmitRetention } from "../lib/paneSubmitRetention.ts"; -import { PanePresentedContext, PaneShownContext, PaneSubmitContext } from "../lib/paneSubmitContext.ts"; -import { PaneAwayContext } from "../lib/paneAwayContext.ts"; +import { PaneCachedContext, PanePresentedContext, PaneShownContext, PaneSubmitContext } from "../lib/paneSubmitContext.ts"; +import { cachedPaneCells, visitPaneTab } from "../lib/paneTabCache.ts"; import { ApiError, routeMissing } from "../lib/api.ts"; import { layoutCells, zoomMode } from "../lib/layoutMap.ts"; import { layoutTopology, paneContextPress, SOLO_PANE_QUERY, soloPane, splitPath, splitRatioAt } from "../lib/paneCanvas.ts"; @@ -27,7 +27,6 @@ interface Props { onSelect: (paneId: string, focusInput?: boolean) => void; onChanged: () => void; renderPane: (pane: HerdrPane | null, active: boolean, split: boolean) => ReactNode; - isTerminal: (pane: HerdrPane) => boolean; /** filled with this canvas's pane actions on each render: a phone's tab menu offers them for the open pane (TabStrip) */ actionsRef?: MutableRefObject<((target: HerdrPane) => RowMenuItem[]) | null>; } @@ -52,18 +51,17 @@ interface Drag { } /** The active tab, drawn from herdr's rectangles. No browser-owned layout or PTY state. */ -export function PaneCanvas({ snapshot, pane, selectedPaneId, onSelect, onChanged, renderPane, isTerminal, actionsRef }: Props) { +export function PaneCanvas({ snapshot, pane, selectedPaneId, onSelect, onChanged, renderPane, actionsRef }: Props) { const t = useT(); const apple = /Mac|iPhone|iPad/.test(navigator.platform); const coarse = useMediaQuery("(pointer: coarse)"); const narrow = useMediaQuery(SOLO_PANE_QUERY); const api = useMachineApi(); const canvas = useRef(null); - // Survives workspace/tab switches, but the PC-keyed canvas owns this memory exclusively. - const awayReleased = useRef(false); const [submits] = useState(() => new PaneSubmitRetention()); const retainedIds = useSyncExternalStore(submits.subscribe, submits.snapshot, submits.snapshot); const lastCells = useRef(new Map[number]>()); + const visitedTabs = useRef([]); useEffect(() => { const live = new Set(snapshot?.panes.map((item) => item.pane_id) ?? []); submits.reconcile(live); @@ -73,7 +71,6 @@ export function PaneCanvas({ snapshot, pane, selectedPaneId, onSelect, onChanged const [renaming, setRenaming] = useState(null); const [closing, setClosing] = useState(null); const [error, setError] = useState(null); - const [passthrough, setPassthrough] = useState>(() => new Set()); // A new pane can be selected before its snapshot arrives, and a selected pane can close // before App confirms its replacement. Keep the prior tab's surviving mounts (and their // connection-owned pending sends) through either gap, with selection/input still pending. @@ -126,9 +123,6 @@ export function PaneCanvas({ snapshot, pane, selectedPaneId, onSelect, onChanged setMenu({ paneId: target.pane_id, anchor, point, previousFocus: document.activeElement instanceof HTMLElement ? document.activeElement : null }); }; - const sendsRightClick = (target: HerdrPane, event: MouseEvent): boolean => - passthrough.has(target.pane_id) && isTerminal(target) && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey - && event.target instanceof Element && !!event.target.closest('.pane-terminal[data-mouse-reporting]'); // a long press on a touch screen is the phone's own: select text, copy (the tab's menu has the pane's actions) const touchPress = useRef(false); const context = (event: MouseEvent, target: HerdrPane): void => { @@ -136,7 +130,6 @@ export function PaneCanvas({ snapshot, pane, selectedPaneId, onSelect, onChanged if (pointer === "touch" || pointer === "pen" || (pointer === undefined && touchPress.current)) return; event.preventDefault(); event.stopPropagation(); - if (sendsRightClick(target, event)) return; const rect = event.currentTarget.getBoundingClientRect(); showMenu(target, event.currentTarget, { x: event.clientX || rect.left + 8, y: event.clientY || rect.top + 8 }); }; @@ -166,8 +159,6 @@ export function PaneCanvas({ snapshot, pane, selectedPaneId, onSelect, onChanged await api.zoomPane(target.pane_id, layout ? zoomMode(layout, target.pane_id) : "on"); if (alive.current && latest.current.tabId === tabId && latest.current.selection === selection) onSelect(target.pane_id); }) }, - { id: "right-click", label: t(passthrough.has(target.pane_id) ? "Use Herdr right-click menu" : "Send right-clicks to pane"), icon: MousePointer2, - run: () => setPassthrough((old) => { const next = new Set(old); if (next.has(target.pane_id)) next.delete(target.pane_id); else next.add(target.pane_id); return next; }) }, { id: "close", label: t("Close pane"), icon: X, danger: true, run: () => requestClose(target) }, ]; const items = target ? itemsFor(target) : []; @@ -213,33 +204,43 @@ export function PaneCanvas({ snapshot, pane, selectedPaneId, onSelect, onChanged flush(current); }; const cells = layout ? layoutCells(layout) : pane ? [{ paneId: pane.pane_id, left: 0, top: 0, width: 100, height: 100, focused: true }] : []; - for (const cell of cells) lastCells.current.set(cell.paneId, cell); + const alone = soloPane(layout, cells.map((cell) => cell.paneId), selectedPaneId, narrow); + const stacked = alone !== null && !layout?.zoomed; + for (const cell of cells) lastCells.current.set(cell.paneId, alone === cell.paneId || stacked + ? { ...cell, left: 0, top: 0, width: 100, height: 100 } : cell); const shownIds = new Set(cells.map((cell) => cell.paneId)); + visitedTabs.current = visitPaneTab(visitedTabs.current, presented?.tab_id ?? null, + new Set(snapshot?.panes.map((item) => item.tab_id) ?? [])); + const cachedCells = cachedPaneCells(visitedTabs.current, presented?.tab_id ?? null, snapshot?.panes ?? [], lastCells.current, shownIds); + const cachedIds = new Set(cachedCells.map((cell) => cell.paneId)); // Every frame stays under the same keyed array, including a frame returning before its ACK. - // Retention ends at the receipt, disconnect timeout, native close, or PC canvas teardown. - const mountedCells = [...cells.map((cell) => ({ ...cell, retained: false })), ...retainedIds.flatMap((id) => { + // Retention ends when the submit/queue settles, on disconnect, native close, or PC canvas teardown. + const mountedCells = [...cells.map((cell) => ({ ...cell, retained: false, cached: false })), + ...cachedCells.map((cell) => ({ ...cell, retained: retainedIds.includes(cell.paneId), cached: true })), + ...retainedIds.flatMap((id) => { const cell = lastCells.current.get(id); - return !shownIds.has(id) && cell ? [{ ...cell, retained: true }] : []; + return !shownIds.has(id) && !cachedIds.has(id) && cell ? [{ ...cell, retained: true, cached: false }] : []; })]; + const mountedIds = new Set(mountedCells.map((cell) => cell.paneId)); + for (const id of lastCells.current.keys()) if (!mountedIds.has(id)) lastCells.current.delete(id); // herdr's zoomed pane, whatever the web selected: the selection follows it (App). A phone has // no room for a split, so it shows the selected pane alone the same way and leaves herdr's // layout as it is; the tab's menu lists the other panes (TabStrip). - const alone = soloPane(layout, cells.map((cell) => cell.paneId), selectedPaneId, narrow); // a phone's hidden siblings take the whole canvas too, so each keeps the grid it is shown with - const stacked = alone !== null && !layout?.zoomed; - return
    + return
    {cells.length === 0 && renderPane(pane, true, false)} {mountedCells.map((cell) => { const item = snapshot?.panes.find((item) => item.pane_id === cell.paneId); if (!item) return null; - const active = !cell.retained && item.pane_id === selectedPaneId; - const hidden = cell.retained || (alone !== null && alone !== item.pane_id); - const single = cells.length === 1 || alone === item.pane_id; + const offTab = cell.retained || cell.cached; + const active = !offTab && item.pane_id === selectedPaneId; + const hidden = offTab || (alone !== null && alone !== item.pane_id); + const single = offTab ? cell.width === 100 && cell.height === 100 : cells.length === 1 || alone === item.pane_id; // Capture contextmenu before xterm focuses its helper textarea; the menu keeps focus on the original pane. return
    { touchPress.current = false; }} @@ -267,10 +268,10 @@ export function PaneCanvas({ snapshot, pane, selectedPaneId, onSelect, onChanged onDropCapture={() => { if (!active) onSelect(item.pane_id, false); }} onFocusCapture={(event) => { if (!active && event.currentTarget.contains(event.target)) onSelect(item.pane_id, false); }} onMouseDownCapture={(event) => { - if (paneContextPress(event, apple) && !sendsRightClick(item, event)) { event.preventDefault(); event.stopPropagation(); } + if (paneContextPress(event, apple)) { event.preventDefault(); event.stopPropagation(); } }} onMouseUpCapture={(event) => { - if (paneContextPress(event, apple) && !sendsRightClick(item, event)) { event.preventDefault(); event.stopPropagation(); } + if (paneContextPress(event, apple)) { event.preventDefault(); event.stopPropagation(); } }} onContextMenuCapture={(event) => context(event, item)}>
    @@ -278,7 +279,7 @@ export function PaneCanvas({ snapshot, pane, selectedPaneId, onSelect, onChanged
    -
    {renderPane(item, active, alone === null && cells.length > 1)}
    +
    {renderPane(item, active, alone === null && cells.length > 1)}
    ; })} {alone === null && layout?.splits.map((item) => { @@ -308,7 +309,7 @@ export function PaneCanvas({ snapshot, pane, selectedPaneId, onSelect, onChanged {closing && setClosing(null)} onConfirm={async () => { await api.closePane(closing.pane_id); setClosing(null); onChanged(); }} />} -
    ; +
    ; } function PaneRename({ pane, onClose, onSave }: { pane: HerdrPane; onClose: () => void; onSave: (label: string) => Promise }) { diff --git a/src/components/PaneTerminal.css b/src/components/PaneTerminal.css index c860269f3..1b0e7afa8 100644 --- a/src/components/PaneTerminal.css +++ b/src/components/PaneTerminal.css @@ -54,7 +54,6 @@ positioned against THIS, never against the stack — the composer and key bar below keep their rows no matter which lens is on */ .terminal-surface { - --terminal-history-gutter: var(--space-3); position: relative; display: flex; flex-direction: column; @@ -162,7 +161,10 @@ /* below a phone's glass header when it lies over the chat (styles.css) */ top: calc(var(--space-2) + var(--header-over, 0px)); right: var(--space-3); - z-index: var(--z-banner); + /* one above the input line and the masked input, which float at --z-banner from a slot later + in the stack (TerminalInput.css): in a short pane a grown line reaches the grid's top, and the + banners and their buttons (Send/Discard, Dismiss) must stay over it */ + z-index: calc(var(--z-banner) + 1); display: flex; flex-direction: column; align-items: flex-end; @@ -196,6 +198,37 @@ } /* not a fault: a lens herdr does not offer on this PC yet */ +/* the selection's quote button: a banner that is a button */ +.terminal-quote { + font-family: inherit; + color: var(--text); + cursor: pointer; + transition: + border-color var(--dur-fast) var(--ease-out), + background-color var(--dur-fast) var(--ease-out); +} + +.terminal-quote svg { + width: var(--icon-size); + height: var(--icon-size); + flex: none; +} + +.terminal-quote:hover { + border-color: var(--accent); +} + +.terminal-quote:focus-visible { + outline: var(--ring); + outline-offset: var(--ring-offset); +} + +@media (prefers-reduced-motion: reduce) { + .terminal-quote { + transition: none; + } +} + .terminal-banner-soon { border-color: var(--accent); color: var(--text); @@ -329,6 +362,3 @@ .terminal-stack.is-chat .pane-terminal { content-visibility: hidden; } - -.pane-terminal.has-viewport-tools { width: calc(100% - var(--terminal-history-gutter)); } -@media (pointer: coarse) { .terminal-surface { --terminal-history-gutter: var(--space-6); } } diff --git a/src/components/PaneTerminal.tsx b/src/components/PaneTerminal.tsx index 121c5d1fc..4bab6fad5 100644 --- a/src/components/PaneTerminal.tsx +++ b/src/components/PaneTerminal.tsx @@ -1,8 +1,8 @@ -import { useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react"; +import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; -import { ChevronRight, Clock, TriangleAlert, X } from "lucide-react"; +import { ChevronRight, ClipboardCheck, Clock, Quote, TriangleAlert, Unplug, X } from "lucide-react"; import "@xterm/xterm/css/xterm.css"; import "./PaneTerminal.css"; @@ -10,22 +10,25 @@ import { HerdrSocket, type SubmitDelivery, type SubmitResult } from "../lib/ws.t import { MachineSession } from "../lib/machineSession.ts"; import { useMachineSessions } from "../lib/machineSessionContext.tsx"; import { ViewportIntentGate } from "../lib/viewportIntent.ts"; -import { PanePresentedContext, PaneShownContext, PaneSubmitContext } from "../lib/paneSubmitContext.ts"; +import { PaneCachedContext, PanePresentedContext, PaneShownContext, PaneSubmitContext } from "../lib/paneSubmitContext.ts"; import { disposeAfterPendingFrame } from "../lib/terminalDispose.ts"; -import { clipboardKey, hasModifiers, physicalKey, terminalChord, navigationSequence, keyFromData, ctrlEnterSequence, modifyOtherKeysLevel, NO_STICKY_MODIFIERS, type StickyModifiers } from "../lib/keys.ts"; +import { clipboardKey, hasModifiers, physicalKey, terminalChord, navigationSequence, keyFromData, ctrlEnterSequence, mirroredCtrlEnterKey, modifyOtherKeysLevel, NO_STICKY_MODIFIERS, type StickyModifiers } from "../lib/keys.ts"; import { keyBarInputSequence, type KeyBarKeyItem } from "../lib/keyBar.ts"; import { EMPTY_DRAFT, applyToDraft, draftIsEmpty, restoreDraft, type InputDraft } from "../lib/draft.ts"; import { messageQueues } from "../lib/messageQueue.ts"; import { pendingMessages } from "../lib/pendingMessages.ts"; import { heldCountShown, heldOpenAtFold, heldOpenOnFocus, heldRefocusDue, heldRowError, heldRowsFold, heldRowsHidden, heldToggleShown, SHORT_PHONE_QUERY } from "../lib/heldRows.ts"; +import { arrangeNotices, dismissNotice, NO_DISMISSALS, settleDismissed, type DismissedNotices } from "../lib/composerNotices.ts"; import { type DraftRefusal, MAX_COMPOSER_CHARS, QUEUE_READY_STATUS, agentDisplayLabel, composerDelivery, composerMessage, composerPayload, submitNote, submitNotTyped, submitRefusal } from "../lib/compose.ts"; import { afterRead, afterSend, afterSettled, composerLift, greetingMemory, rememberGreeting, greetingFits, greetingFolder, roomOverComposer, showsGreeting, type ChatRead } from "../lib/greeting.ts"; +import { DETACHED, usePaneBranch } from "../lib/paneBranch.ts"; import { answerFromText, answerHint, answerRefusal, needsConfirmation, type TypedAnswer } from "../lib/promptAnswer.ts"; import { ApiError, assertAttachable, fetchPaneScroll, fetchPaneSelection, scrollPane } from "../lib/api.ts"; import { parseOsc52 } from "../lib/osc52.ts"; +import { composerDrafts } from "../lib/composerDraft.ts"; +import { appendQuote, formatTerminalQuote } from "../lib/terminalQuote.ts"; import { matchHerdrWidths } from "../lib/terminalWidths.ts"; import { useMachineApi, useMachineId } from "../lib/machineContext.tsx"; -import { PaneAwayContext } from "../lib/paneAwayContext.ts"; import { paneStorageId } from "../../shared/machines.ts"; import { KeyBar } from "./KeyBar.tsx"; import { TerminalInput } from "./TerminalInput.tsx"; @@ -36,15 +39,17 @@ import { secretPrompt } from "../../shared/secret-prompt.ts"; import { ChatView } from "./ChatView.tsx"; import { RenderBoundary } from "./RenderBoundary.tsx"; import { Composer } from "./Composer.tsx"; +import { ComposerNotices, type ComposerNoticeRow } from "./ComposerNotices.tsx"; import { PendingMessages } from "./PendingMessages.tsx"; import type { AgentStatus, ClientRole, ConversationMetadata, InteractivePrompt, PlanSummary, ServerMessage } from "../../shared/protocol.ts"; import type { PaneControls, PaneView } from "../lib/actions.ts"; import { usePaneLane } from "../lib/chatLane.ts"; -import { useSettings, terminalTheme, type Palette, type ResolvedTheme, type TerminalCursorStyle } from "../lib/settings.ts"; +import { useSettings, terminalTheme, type Palette, type ResolvedTheme } from "../lib/settings.ts"; import { loadFontStack, TERMINAL_FONT_STACK, terminalFontStack } from "../lib/fontFamily.ts"; import { useT } from "../lib/i18n.ts"; import { isAppShortcut } from "../lib/shortcuts.ts"; import { plainPageDirection } from "../lib/pageScroll.ts"; +import { keyboardHoldsGrid, keyboardUp } from "../lib/keyboard.ts"; import { OpenFileContext } from "../lib/filePaths.ts"; import { fileUriPath, hyperlinkFilter, isWebLink, terminalFileLinkProvider } from "../lib/terminalFileLinks.ts"; import { adjustTerminalGlyphs } from "../lib/terminalGlyphs.ts"; @@ -53,6 +58,7 @@ import { useMediaQuery } from "../lib/useMediaQuery.ts"; import { modalOpen } from "../lib/focus.ts"; import { pinchFontSize } from "../lib/pinchFontSize.ts"; import { narrowPaneFontSize } from "../lib/narrowPaneFont.ts"; +import { finalStep, HERDR_MOUSE_SCROLL_LINES, wheelSteps } from "../lib/wheelSteps.ts"; /** How long a resize must rest before the grid refits and the pty follows it. */ const RESIZE_SETTLE_MS = 120; @@ -68,6 +74,8 @@ const inUse = (): boolean => document.visibilityState === "visible" && document. export interface PaneTerminalProps { /** The pane this terminal attaches to; null renders the placeholder. */ paneId: string | null; + /** null while the PC identity loads, before the first attach's fixed-grid frame arrives. */ + mirrored?: boolean | null; /** * herdr's reason it could not restore the selected pane (0.9.3+): the pane has no * terminal to attach, so App passes a null paneId and the placeholder says why. @@ -87,6 +95,10 @@ export interface PaneTerminalProps { backgroundWait?: boolean; /** the pane's working directory and its PC's name: an empty chat's greeting names them */ cwd?: string | null; + /** the pane's workspace: its checkout's branch stands beside the folder under the composer */ + workspaceId?: string | null; + /** that workspace is a linked worktree of this repository (its name, as herdr gives it); null: it is not one */ + worktreeRepo?: string | null; machineName?: string; /** the lens over the pane: the chat transcript, or the live xterm grid (App remembers it per pane) */ view: PaneView; @@ -100,10 +112,6 @@ export interface PaneTerminalProps { terminalFontSize: number; /** one of several panes drawn side by side: under 80 columns its font shrinks to reach them */ split?: boolean; - /** xterm cursorStyle (settings); a program's own DECSCUSR still changes it, as before this setting existed */ - terminalCursorStyle: TerminalCursorStyle; - /** xterm cursorBlink (settings) */ - terminalCursorBlink: boolean; /** mouse reports sent per wheel event (settings): 1 is xterm's own one report */ terminalWheelSpeed: number; /** fonts tried before the built-in stack (settings); "" keeps the built-in one */ @@ -135,6 +143,7 @@ const heldRowNotes = new Map(null); const submitRetention = useContext(PaneSubmitContext); const presented = useContext(PanePresentedContext); const presentedRef = useRef(presented); presentedRef.current = presented; + const cached = useContext(PaneCachedContext); + const cachedRef = useRef(cached); cachedRef.current = cached; // a split tab's pane a phone keeps behind the one it shows sizes nothing: the pty is shared with // every device, and a PC drawing the split would see it at the phone's size (#819) const shown = useContext(PaneShownContext); const shownRef = useRef(shown); shownRef.current = shown; const sizesPty = (): boolean => presentedRef.current && shownRef.current; - const awayReleased = useContext(PaneAwayContext); const { answerPanePrompt, uploadPaneImage } = useMachineApi(); const uploadFileRef = useRef(uploadPaneImage); uploadFileRef.current = uploadPaneImage; @@ -187,10 +198,8 @@ export function PaneTerminal({ const [findOpen, setFindOpen] = useState(false); const [terminal, setTerminal] = useState(null); const [searchState, setSearchState] = useState(null); - const viewportId = useId(); const viewportIntent = useMemo(() => new ViewportIntentGate(), [machineId, paneId]); const viewportSearching = useSyncExternalStore(viewportIntent.subscribe, viewportIntent.isSearching, viewportIntent.isSearching); - const [mouseReporting, setMouseReporting] = useState(false); const findOpenRef = useRef(findOpen); findOpenRef.current = findOpen; const lastFindRequest = useRef(findRequest); @@ -248,8 +257,15 @@ export function PaneTerminal({ const composingRef = useRef(false); const [composing, setComposingState] = useState(false); const setComposing = useCallback((active: boolean) => { composingRef.current = active; setComposingState(active); }, []); + // a composition in the input line keeps the key bar live but blocks the switch to direct typing: + // unmounting the line mid-composition could drop the word or replay it into the pane + const lineComposingRef = useRef(false); + const [lineComposing, setLineComposingState] = useState(false); + const setLineComposing = useCallback((active: boolean) => { lineComposingRef.current = active; setLineComposingState(active); }, []); const compositionCommitPendingRef = useRef(false); const barKeyRef = useRef<{ key: string; chord: string | null } | null>(null); + /** set while the composer's Stop types its ESC, so it goes out as a Stop (ClientMessage `input.stop`) */ + const stopKeyRef = useRef(false); const modifiersRef = useRef(NO_STICKY_MODIFIERS); const [modifiers, setModifiers] = useState(NO_STICKY_MODIFIERS); const clearModifiers = useCallback(() => { @@ -262,8 +278,11 @@ export function PaneTerminal({ const observeRef = useRef(false); // a mirrored pane (no terminal attach on its PC): the grid is the pane's own in herdr, adopted like an observer's const fixedGridRef = useRef(false); + const mirroredRef = useRef(mirrored); mirroredRef.current = mirrored; // the pty's grid as the server last said it for this pane (pane-geometry), whoever set it const sharedGridRef = useRef<{ cols: number; rows: number } | null>(null); + // takes the pty back to this grid when another device left it at its own (the effect below sets it) + const reassertIfStaleRef = useRef<() => void>(() => undefined); // out of use, the tab let go of its pane (no attach) so herdr's own TUI has it back; it attaches // again when the user is back. The ref is what the socket handlers and the pane switch read const releasedRef = useRef(false); @@ -274,14 +293,24 @@ export function PaneTerminal({ const watchingRef = useRef(false); const [watching, setWatchingState] = useState(false); const setWatching = useCallback((next: boolean) => { watchingRef.current = next; setWatchingState(next); }, []); - // the mount effect's watch, for the pane switch: a released tab moving on to the next pane watches that one - const watchRef = useRef<(pane: string) => void>(() => {}); + // the mount effect's initial-attach decision, also used for a replacement pane + const deferAttachRef = useRef<(pane: string) => boolean>(() => false); // the mount effect's release, for the queue below: a tab that kept its pane for a message lets go once it went const releaseRef = useRef<() => void>(() => {}); + const presentationRef = useRef<() => void>(() => {}); + // A cache release resumes from the existing screen, not the observer's replacement screen. + const cachedReleaseRef = useRef(false); // the mount effect's leave, for the pane switch: a tab out of use from the start (a reload behind // another app) or moving on to the next pane there lets go of it too const leaveRef = useRef<() => void>(() => {}); const cancelLeaveRef = useRef<() => void>(() => {}); + // the mount effect's in-place re-attach after the output stopped, for the banner's Reconnect + const reattachRef = useRef<() => void>(() => {}); + // the mount effect's keyboard hold, for the fits outside it: before a fit, whether the soft keyboard + // holds the grid (the fit then waits for the keyboard to go; `force` says it was to re-assert the + // grid), and after one, that the grid is the host's again + const keyboardHoldsRef = useRef<(force: boolean) => boolean>(() => false); + const fittedRef = useRef<() => void>(() => {}); const pendingSubmissionsRef = useRef(new Set<{ pane: string; socket: HerdrSocket; epoch: number }>()); const endedRef = useRef(false); // the modifyOtherKeys level the pane's program asked for, as this pane's stream last said it @@ -317,7 +346,7 @@ export function PaneTerminal({ // Settings → Use alongside herdr's own window: off (the default), a tab out of use keeps its pane and its size const releaseAwayRef = useRef(settings.releasePaneAway); releaseAwayRef.current = settings.releasePaneAway; - const narrowFont = split && settings.narrowPaneFont; + const narrowFont = split; const narrowFontRef = useRef(narrowFont); narrowFontRef.current = narrowFont; const narrowFontFitted = useRef(narrowFont); @@ -335,6 +364,11 @@ export function PaneTerminal({ setDraftState((previous) => ({ owner, value: typeof value === "function" ? value(previous.owner === owner ? previous.value : EMPTY_DRAFT) : value })); }, [machineId]); const draftPaneRef = useRef(null); + // The pane whose held draft takes all later typing until Send or Discard: keys sent beside it would + // reach the pty without the start the draft holds (`echo ` held, `rm -rf build` and Enter typed). + // Set here on each render, and at once by onData when it holds a key, before that render comes. + const draftHeldForRef = useRef(null); + draftHeldForRef.current = paneId !== null && draftState.owner === paneStorageId(machineId, paneId) && !draftIsEmpty(draft) ? paneId : null; useEffect(() => { if (!paneId || draftState.owner !== paneStorageId(machineId, paneId)) return; const key = `herdr-web-ui:terminal-draft:${draftState.owner}`; @@ -343,6 +377,10 @@ export function PaneTerminal({ // transient OSC 52 feedback ("copied") — a pill in the banner column const [clipboardNote, setClipboardNote] = useState(null); const clipboardTimerRef = useRef(null); + // the notices the user put away over the input card (lib/composerNotices.ts) + const [dismissedNotices, setDismissedNotices] = useState(NO_DISMISSALS); + // the text of the last drag, offered to the chat draft while its highlight stays (herdr's rows when it gave them) + const [selectionQuote, setSelectionQuote] = useState<{ pane: string; text: string; range: { start: number; end: number } | null } | null>(null); // Explicit sends refresh immediately; continuous transcript invalidations are rate-limited. const [chatRefresh, setChatRefresh] = useState(0); const [chatInvalidation, setChatInvalidation] = useState(0); @@ -430,6 +468,22 @@ export function PaneTerminal({ const queueOwner = paneId === null ? null : paneStorageId(machineId, paneId); const queued = useSyncExternalStore(queueStore.subscribe, () => queueStore.read(queueOwner ?? "")); const pending = useSyncExternalStore(pendingMessages.subscribe, () => pendingMessages.read(queueOwner ?? "")); + useEffect(() => { + if (!paneId || !queueOwner || !submitRetention) return; + let release: (() => void) | undefined; + const retainPending = (): void => { + const scope = sessionRef.current?.scope ?? null; + const waits = pendingMessages.read(queueOwner).some((message) => pendingMessages.isOwned(queueOwner, message.id, scope) + && (message.state === "queued" || message.state === "sending")); + if (waits) release ??= submitRetention.retain(paneId); + else { release?.(); release = undefined; } + }; + // Transfer retention before the submit promise releases its receipt claim. A React + // render/effect after that release would let navigation unmount the pane in between. + const off = pendingMessages.subscribe(retainPending); + retainPending(); + return () => { off(); release?.(); }; + }, [paneId, queueOwner, submitRetention]); const sendingRef = useRef(false); const [queueSending, setQueueSending] = useState(null); // a refused Send now's note outlives this mount: a pane left for another workspace unmounts, and @@ -479,8 +533,9 @@ export function PaneTerminal({ const linkPressed = (event: MouseEvent): boolean => event.button === 0 && !term.hasSelection(); const term = new Terminal({ convertEol: false, - cursorStyle: terminalCursorStyle, - cursorBlink: terminalCursorBlink, + // a program's own DECSCUSR still changes the cursor + cursorStyle: "block", + cursorBlink: true, // xterm keeps no scrollback: the attach stream lives in the alternate screen and herdr // owns scrollback (wheel and touch go to it). With scrollback on, the fit addon reserves // a scrollbar column - 15px by fallback wherever scrollbars are overlays - and the last @@ -641,16 +696,53 @@ export function PaneTerminal({ // the grid while the row fits there, else the bottom rows, where a prompt sits. A mirror // has no cursor; xterm's own rests on the last row with text, which serves the same. const adopted = (): boolean => observeRef.current || fixedGridRef.current; + // The soft keyboard takes the bottom of the screen, not the pane: a fit to what is left above + // it would size the shared pty to that strip, and every other viewer of the pane, herdr's own + // window included, would reflow twice per keyboard (#1045). While the keyboard is up, a host + // that only lost height keeps its grid and the view pans to the cursor as an adopted grid's + // does; the fit it held back runs once the keyboard goes (the host grows, the observer fits). + let keyboardHeld = false; + /** the host's width at its last fit: a width that changed (a rotation) refits under the keyboard too */ + let fittedWidth: number | null = null; + /** a fit the keyboard held back was to re-assert the grid (force): the catch-up fit does so */ + let heldForce = false; let panned = false; const followCursor = (): void => { host.toggleAttribute("data-adopted-grid", adopted()); const screen = term.element?.querySelector(".xterm-screen"); - if (!adopted() || panned || !screen) return; + if (!(adopted() || keyboardHeld) || panned || !screen) return; const row = screen.offsetHeight / term.rows; const cursorBottom = screen.offsetTop + (term.buffer.active.cursorY + 1) * row; const max = host.scrollHeight - host.clientHeight; host.scrollTop = cursorBottom <= host.clientHeight ? 0 : cursorBottom - row >= max ? max : cursorBottom - host.clientHeight; }; + /** before a fit: true when the keyboard holds the grid, and the fit waits (`force`: it was to re-assert the grid) */ + const keyboardHolds = (force: boolean): boolean => { + if (!keyboardHoldsGrid(keyboardUp(), host.clientWidth, fittedWidth)) return false; + keyboardHeld = true; + heldForce ||= force; + // the pane may be at another device's grid: drawn as it is until the keyboard goes, when the + // catch-up fit takes the pane back (as a tap or key in the grid would) + const shared = sharedGridRef.current; + if (shared && !adopted() && (term.cols !== shared.cols || term.rows !== shared.rows)) { + term.resize(shared.cols, shared.rows); + heldForce = true; + } + panned = false; + followCursor(); + return true; + }; + /** after a fit: the grid is the host's again, with nothing left to pan to */ + const fitted = (): void => { + fittedWidth = host.clientWidth; + keyboardHeld = false; + heldForce = false; + // xterm's helper textarea can sit at the old grid's cursor row for a while and keep the host + // scrollable: a view panned under the keyboard would stay panned past the grid's top + host.scrollTop = 0; + }; + keyboardHoldsRef.current = keyboardHolds; + fittedRef.current = fitted; /** herdr's text for the last drag, while its highlight is still the selection */ let copiedText: string | null = null; @@ -686,6 +778,7 @@ export function PaneTerminal({ if (!term.hasSelection()) { copiedText = null; selectionGeneration++; + setSelectionQuote(null); } }); @@ -765,6 +858,9 @@ export function PaneTerminal({ sendScroll(d); }); }; + /** one row's height on screen, in CSS pixels (a selecting drag's wheel and a finger's travel) */ + const cellHeight = (): number => + (term.element?.querySelector(".xterm-screen")?.getBoundingClientRect().height ?? term.rows) / term.rows; /** positive lines show older text */ const scrollDrag = (d: Drag, lines: number): void => { if (d.top === null) return; @@ -779,11 +875,11 @@ export function PaneTerminal({ const dragWheel = (event: WheelEvent): void => { const d = drag; if (!d || d.top === null) return; - const lineHeight = (term.element?.querySelector(".xterm-screen")?.getBoundingClientRect().height ?? term.rows) / term.rows; - d.wheelPixels += event.deltaMode === WheelEvent.DOM_DELTA_LINE ? event.deltaY * lineHeight : event.deltaY; - const lines = Math.trunc(d.wheelPixels / lineHeight); + const lineHeight = cellHeight(); + const { steps: lines, rest } = wheelSteps( + d.wheelPixels + (event.deltaMode === WheelEvent.DOM_DELTA_LINE ? event.deltaY * lineHeight : event.deltaY), lineHeight); + d.wheelPixels = rest; if (lines === 0) return; - d.wheelPixels -= lines * lineHeight; scrollDrag(d, -lines); }; const stopEdge = (): void => { @@ -806,6 +902,7 @@ export function PaneTerminal({ if (reporting) Object.defineProperty(event, isMac ? "altKey" : "shiftKey", { value: true }); copiedText = null; selectionGeneration++; + setSelectionQuote(null); const pane = paneRef.current; const { cell } = cellAt(event); const d: Drag = { @@ -886,6 +983,8 @@ export function PaneTerminal({ copySelection(); const generation = selectionGeneration; const current = (): boolean => !disposed && paneRef.current === d.pane && generation === selectionGeneration; + // offered to the chat draft at once with what is on screen; herdr's text and rows replace it below + if (d.pane !== "") setSelectionQuote({ pane: d.pane, text: visibleText, range: null }); let range: [Cell, Cell] | null = null; if (d.scrolled) range = historyRange(d); else if (d.top !== null) { @@ -899,14 +998,19 @@ export function PaneTerminal({ } } if (!range) return; + // herdr's text covers the whole range; the visible text in its place does not, so the quote + // then names no line range rather than one its text does not hold + let fromHerdr = true; const text = fetchPaneSelection(d.pane, range[0], range[1], machineId).catch(() => { if (current() && d.scrolled) noteClipboard("could not read the selection from herdr"); + fromHerdr = false; return visibleText; }).then((value) => { if (!current()) throw new Error("selection changed"); // A redraw can remove the range before herdr reads it. Keep what the user // actually selected instead of replacing a successful copy with nothing. copiedText = value || visibleText; + setSelectionQuote({ pane: d.pane, text: copiedText, range: fromHerdr && value ? { start: range[0].row, end: range[1].row } : null }); return copiedText; }); // Reserve the write NOW, supplying the server text when it arrives. Never @@ -969,6 +1073,7 @@ export function PaneTerminal({ pendingEpochRef.current = session.epoch; socketRef.current = socket; let outputGeneration = 0; + let deferredAttach = false; const off = session.on((message, sent) => { onServerMessageRef.current?.(message); pendingScopeRef.current = session.scope; @@ -981,8 +1086,11 @@ export function PaneTerminal({ stalledRef.current = false; setOutputError(null); setEnded(false); - term.options.disableStdin = observeRef.current || secretRef.current !== null || heldRef.current; + term.options.disableStdin = observeRef.current || heldRef.current; } + // A provisional watch must not strand an initial/reconnected pane on an older bridge. + // An ordinary opt-in release still keeps its existing paused fallback. + if (message.type === "snapshot" && deferredAttach && !socket.canWatch()) resume(); if (message.type === "pending-messages" && sent) { redrawGreeting(); onChatSuggestion(message.pane_id, null); @@ -1014,7 +1122,6 @@ export function PaneTerminal({ return; } setOutputReady(true); - setMouseReporting(term.modes.mouseTrackingMode !== "none" && !fixedGridRef.current); followCursor(); const lines: string[] = []; const buffer = term.buffer.active; @@ -1024,9 +1131,12 @@ export function PaneTerminal({ if (line?.isWrapped && lines.length) lines[lines.length - 1] += text; else lines.push(text); } + // A masked prompt does not close the grid: a desktop answers it there, as in any terminal, + // and the masked input stands by for a phone (#1031). onData keeps what is typed then out + // of every draft. const prompt = secretPrompt(lines.join("\n"), term.cols); secretRef.current = prompt; - term.options.disableStdin = observeRef.current || prompt !== null || heldRef.current; + term.options.disableStdin = observeRef.current || heldRef.current; setSecret((previous) => previous?.pane === owner && previous.prompt === prompt ? previous : prompt ? { pane: owner, prompt } : null); }); } else if (message.type === "watch-data") { @@ -1039,7 +1149,7 @@ export function PaneTerminal({ } else if (message.type === "attach-resumed") { if (message.pane_id === paneRef.current) { setHeld(false); - term.options.disableStdin = observeRef.current || secretRef.current !== null; + term.options.disableStdin = observeRef.current; } } else if (message.type === "pty-exit") { // an ended pane takes no input: a file dropped on it must not upload and paste either @@ -1053,11 +1163,12 @@ export function PaneTerminal({ // no fit runs while observing: a font shrunk for this client's own grid would stay if (nowObserving) term.options.fontSize = baseFontRef.current; setObserving(nowObserving); - term.options.disableStdin = nowObserving || secretRef.current !== null || heldRef.current; + term.options.disableStdin = nowObserving || heldRef.current; onRoleAckRef.current?.(message.mode); - if (!nowObserving && !fixedGridRef.current && !chatViewRef.current && inUse()) { + if (!nowObserving && !fixedGridRef.current && !chatViewRef.current && inUse() && !keyboardHolds(true)) { try { fit.fit(); + fitted(); } catch { /* not laid out yet */ } @@ -1079,7 +1190,12 @@ export function PaneTerminal({ // chat lens entered later must draw its hidden screen for that grid, not this device's sharedGridRef.current = { cols: message.cols, rows: message.rows }; // the chat lens adopts the shared grid too: the screen it reads (a masked prompt) is drawn for it - if (!observeRef.current && !fixedGridRef.current && !chatViewRef.current) return; + if (!observeRef.current && !fixedGridRef.current && !chatViewRef.current) { + if (!keyboardHeld) return; + // a grid held under the keyboard draws the pane's own grid (an attach made under it adopted + // the pane's size, another device may have sized it): the catch-up fit takes the pane back + heldForce = true; + } if (term.cols !== message.cols || term.rows !== message.rows) term.resize(message.cols, message.rows); panned = false; followCursor(); @@ -1097,6 +1213,15 @@ export function PaneTerminal({ setConnected(socket.connected); return; } + if (message.code === "attach_failed") { + if (message.pane_id !== undefined && message.pane_id !== paneRef.current) return; + // the server could not start herdr: nothing is coming, so say why instead of waiting + setOutputError(t("herdr could not be started on the server, so this terminal cannot attach. Put herdr on the server's PATH or set HERDR_WEB_HERDR_BIN to its full path.")); + setEnded(true); + term.options.disableStdin = true; + setConnected(socket.connected); + return; + } if (message.code === "terminal_unsupported") { if (message.pane_id !== undefined && message.pane_id !== paneRef.current) return; setUnsupported(true); @@ -1110,6 +1235,13 @@ export function PaneTerminal({ setEnded(true); setConnected(false); term.options.disableStdin = true; + // a frozen page reads the close after it is shown again, often after back() ran: the + // stop belongs to the time it was put away, so attach again now (see back()) + if (message.code === "output_stalled" && socket.stoppedWhileHidden && inUse()) { + queueMicrotask(() => { + if (!disposed && stalledRef.current && socket.stoppedWhileHidden && inUse()) reattachRef.current(); + }); + } return; } term.writeln(`\r\n\u001b[31m[herdr-web-ui] ${message.code}: ${message.message}\u001b[0m`); @@ -1134,20 +1266,31 @@ export function PaneTerminal({ }); session.connect(); - const poll = window.setInterval(() => { setConnected(socket.connected); setConversationPushes(socket.conversationPushes()); }, 1000); + // input readiness too: a liveness probe refuses input without a frame to say so + const poll = window.setInterval(() => { + setConnected(socket.connected); + setConversationPushes(socket.conversationPushes()); + if (paneRef.current) setInputReady(socket.canInput(paneRef.current)); + }, 1000); // onKey runs after xterm drains a pending IME commit, immediately before the // key's onData. Remap only that CR, preserving composition text and its order. // xterm.js types Shift+Enter and Ctrl+Enter as plain Enter: Shift+Enter becomes the Alt+Enter // newline chord, Ctrl+Enter what modifyOtherKeys makes it (Claude Code's "send now") while - // the program has asked for that, and stays Enter otherwise. + // the program has asked for that, and stays Enter otherwise. A mirrored pane never hears that + // request, so its Ctrl+Enter goes as herdr's key, which herdr encodes as the program asked. let enterAs: string | null = null; + let enterKey: string | null = null; const onModifiedEnter = term.onKey(({ key, domEvent: event }) => { enterAs = null; + enterKey = null; if (term.options.disableStdin || key !== "\r" || event.key !== "Enter" || event.altKey || event.metaKey || event.isComposing || event.keyCode === 229) return; if (event.shiftKey && !event.ctrlKey) enterAs = "\x1b\r"; - else if (event.ctrlKey && !event.shiftKey) enterAs = ctrlEnterSequence(modifyOtherKeysRef.current); + else if (event.ctrlKey && !event.shiftKey) { + enterKey = mirroredCtrlEnterKey(fixedGridRef.current, modifyOtherKeysRef.current); + enterAs = enterKey !== null ? "\r" : ctrlEnterSequence(modifyOtherKeysRef.current); + } }); // Let xterm finish pending IME text before remapping the key's following data. // Cmd+Backspace uses the terminal's Ctrl+U line-deletion shortcut on macOS. @@ -1183,20 +1326,56 @@ export function PaneTerminal({ const onData = term.onData((data) => { const barKey = barKeyRef.current; barKeyRef.current = null; + const stopKey = stopKeyRef.current; + stopKeyRef.current = false; const physicalChord = typedChord; typedChord = null; - if (enterAs !== null && data === "\r") data = enterAs; + // a modified Enter (Shift+Enter's newline, Ctrl+Enter's "send now") is decided on before its + // rewrite: its sequence may carry no line break at all + const modifiedEnter = enterAs !== null && data === "\r"; + const enterChord = modifiedEnter ? enterKey : null; + if (modifiedEnter) data = enterAs!; enterAs = null; + enterKey = null; if (commandBackspace && data === "\x7f") data = "\x15"; commandBackspace = false; const current = paneRef.current; - if (!current || observeRef.current || secretRef.current !== null || heldRef.current) return; + // Hiding a cached pane blurs xterm, which may commit its pending IME text. + // That abandoned input must reach neither the old PTY nor its held draft. + if (!current || !presentedRef.current || !shownRef.current || observeRef.current || heldRef.current) return; + // while a masked prompt is on screen, what is typed goes to the program or nowhere: never + // into a draft, which is kept in the clear and offered back for review + const secret = secretRef.current !== null; const input = data; const key = keyFromData(data); - const chord = !pasting && !composingRef.current && !compositionCommitPendingRef.current + const chord = enterChord ?? (!pasting && !composingRef.current && !compositionCommitPendingRef.current ? barKey !== null ? barKey.chord : physicalChord ?? (hasModifiers(modifiersRef.current) && key !== null ? terminalChord(key, modifiersRef.current) : null) - : null; + : null); + if (draftHeldForRef.current === current) { + // A chord or a modified Enter is never held, and never sent beside held text either + // (Ctrl+J, or Ctrl+Enter under modifyOtherKeys, would run the line). + // A paste the draft cannot take (an unbracketed tab or control character) is refused too, never sent + // ahead of the held start. + const pasted = pasting || input.startsWith("\x1b[200~"); + if (chord !== null || modifiedEnter || (pasted && applyToDraft(EMPTY_DRAFT, input) === EMPTY_DRAFT)) { + const t = tRef.current; + setInputError(t("Not sent: send or discard the held input first.")); + return; + } + // Text, a paste, Backspace and anything with a line break go to the draft, which leaves + // Enter out, so a partial command never runs. Other control keys (Ctrl+C, Escape, Tab, + // arrows) still reach the program below: an interrupt must not wait on the draft. + if (pasted || /[\r\n\x7f\b]/.test(input) || applyToDraft(EMPTY_DRAFT, input) !== EMPTY_DRAFT) { + if (secret) { + const t = tRef.current; + setInputError(t("Not sent: send or discard the held input first.")); + return; + } + setDraft((prev) => applyToDraft(prev, input)); + return; + } + } // Herdr, rather than xterm's legacy encoder, preserves all modifier bits // in the keyboard protocol requested by the program in this pane. // herdr's attach stream never tells xterm that the program asked for application cursor @@ -1215,7 +1394,12 @@ export function PaneTerminal({ } return; } - if (socket.sendInput(current, input)) return; + if (socket.sendInput(current, input, stopKey && input === "\u001b")) return; + if (secret) { + const t = tRef.current; + setInputError(t("Not sent: the terminal is not ready, and a password is never held for later.")); + return; + } // A closed socket, an attachment still opening, or a failed synchronous send: // keep printable input for explicit review, never replay it automatically. if (draftPaneRef.current !== current) { @@ -1223,6 +1407,7 @@ export function PaneTerminal({ setDraft(EMPTY_DRAFT); } setDraft((prev) => applyToDraft(prev, input)); + if (!draftIsEmpty(applyToDraft(EMPTY_DRAFT, input))) draftHeldForRef.current = current; }); // Browsers expose dropped files as bytes, not local paths. Save them beside @@ -1302,14 +1487,19 @@ export function PaneTerminal({ // the chat lens covers the grid: a phone's viewport or keyboard must not resize the // shared pty under another device (#361); the switch back to the terminal refits if (chatViewRef.current || !sizesPty()) return; + // the keyboard took the bottom of the screen, not the pane: the grid stays, the view pans + if (keyboardHolds(false)) return; + // a fit the keyboard held back re-asserts the grid as it would have (a lens or role return) + const force = heldForce; try { fit.fit(); + fitted(); } catch { return; } // a window the system moves or resizes in the background fits its own grid only const current = paneRef.current; - if (current && inUse()) socket.resize(current, term.cols, term.rows); + if (current && inUse()) socket.resize(current, term.cols, term.rows, force); }, RESIZE_SETTLE_MS); }); observer.observe(host); @@ -1326,6 +1516,8 @@ export function PaneTerminal({ // Two fingers pinch the font size: the font follows them locally, and the shared pty resizes once when one lifts. let touchX = 0; let touchY = 0; + /** finger travel of this gesture not yet sent as a wheel report */ + let touchPixels = 0; let tracking = false; let pinch: { first: number; second: number; startSize: number; startDistance: number; size: number } | null = null; // from a second finger until every finger lifts: the stroke is a pinch, never a scroll @@ -1340,8 +1532,10 @@ export function PaneTerminal({ baseFontRef.current = size; term.options.fontSize = size; if (adopted() || chatViewRef.current) return; + // a pinch under the keyboard fits what the finger sees: the keyboard going refits the rows try { fit.fit(); + fitted(); } catch { return; } @@ -1394,6 +1588,7 @@ export function PaneTerminal({ return; } tracking = event.touches.length === 1; + touchPixels = 0; const first = event.touches[0]; if (tracking && first) { touchX = first.clientX; @@ -1440,12 +1635,30 @@ export function PaneTerminal({ host.scrollBy(across, delta); return; } - if (delta !== 0) { - const target = term.element ?? host; - target.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: delta, clientX: first.clientX, clientY: first.clientY })); + // xterm sends herdr one report per wheel event however far it went, and herdr scrolls a + // few lines per report: one line-mode wheel per that many rows of finger travel keeps a + // swipe as far as a slow drag, and the text under the finger + const { steps, rest } = wheelSteps(touchPixels + delta, cellHeight() * HERDR_MOUSE_SCROLL_LINES); + touchPixels = rest; + sendTouchWheels(steps); + }; + /** `steps` line-mode wheel reports at the finger, each one herdr scroll step in the sign's direction */ + const sendTouchWheels = (steps: number): void => { + const target = term.element ?? host; + for (let sent = 0; sent < Math.abs(steps); sent += 1) { + target.dispatchEvent(new WheelEvent("wheel", { + bubbles: true, cancelable: true, deltaY: Math.sign(steps), deltaMode: WheelEvent.DOM_DELTA_LINE, + clientX: touchX, clientY: touchY, + })); } }; const onTouchEnd = (event: TouchEvent): void => { + // a lifted one-finger drag sends what is left once it reaches half a step: a flick shorter + // than one step still scrolls, and a long drag loses at most half a step at its end + if (tracking && event.type === "touchend" && !pinchStroke && !adopted()) { + sendTouchWheels(finalStep(touchPixels, cellHeight() * HERDR_MOUSE_SCROLL_LINES)); + } + touchPixels = 0; tracking = false; if (!pinchStroke) return; claimPinch(event); @@ -1469,9 +1682,10 @@ export function PaneTerminal({ // connections never do this: they own no geometry to re-assert. const refit = (): void => { const current = paneRef.current; - if (!current || observeRef.current || fixedGridRef.current || chatViewRef.current || !sizesPty()) return; + if (!current || observeRef.current || fixedGridRef.current || chatViewRef.current || !sizesPty() || keyboardHolds(true)) return; try { fit.fit(); + fitted(); } catch { return; } @@ -1506,18 +1720,23 @@ export function PaneTerminal({ const release = (): void => { cancelLeave(); const current = paneRef.current; - if (!current || !(releaseAwayRef.current || hiddenOnTouch()) || releasedRef.current || inUse() || embedded || fixedGridRef.current || endedRef.current) return; + if (!current || embedded || mirroredRef.current || fixedGridRef.current) return; + if (cachedRef.current && releasedRef.current) { + if (watchingRef.current) { socket.unwatch(current); setWatching(false); } + // Caching stops the watch, but does not turn an away release into a cache-only release. + return; + } + if (releasedRef.current || (!cachedRef.current + && (!(releaseAwayRef.current || hiddenOnTouch()) || inUse() || endedRef.current))) return; if (queueWaits(current)) { queueHeld = true; return; } socket.detach(current); // The server's detach receipt holds pending input on this still-live PC connection. - // Only an actual release marks the canvas. A queue-held pane never transfers that - // ownership or skips the next pane's normal initial attach/release delay. - if (awayReleased) awayReleased.current = true; setReleased(true); - watch(current); + cachedReleaseRef.current = cachedRef.current; + if (!cachedRef.current) watch(current); }; // the pane's screen starts again from the view, as on a pane switch; the view's first frame is whole const watch = (pane: string): void => { @@ -1530,8 +1749,27 @@ export function PaneTerminal({ modifyOtherKeysRef.current = 0; setWatching(socket.watch(pane, term.cols, term.rows)); }; + // keep_size prevents resizing an existing attach, but a fresh sidecar still sizes the pane. + // The client cannot distinguish those cases atomically, so both watch until the user returns. + const deferAttach = (pane: string): boolean => { + deferredAttach = false; + if (inUse() || embedded || mirroredRef.current || fixedGridRef.current || queueWaits(pane) || !socket.canWatch()) return false; + cancelLeave(); + deferredAttach = true; + setReleased(true); + cachedReleaseRef.current = false; + // The local roster can arrive before /api/health identifies a mirror. Wait for that + // identity rather than briefly starting an observer on a PC with no attach sidecar. + if (!cachedRef.current && mirroredRef.current !== null) watch(pane); + return true; + }; + deferAttachRef.current = deferAttach; + const offBeforeReplay = socket.onBeforeReplay(() => { + const pane = paneRef.current; + if (!pane || releasedRef.current) return; + if (deferAttach(pane)) socket.detach(pane); + }); releaseRef.current = () => { if (queueHeld) release(); }; - watchRef.current = watch; // a window resized while watched: the view is drawn for a grid, so it starts again at the new one const onWatchedResize = term.onResize(({ cols, rows }) => { const current = paneRef.current; @@ -1541,6 +1779,7 @@ export function PaneTerminal({ }); const resume = (): void => { if (!presentedRef.current) return; + deferredAttach = false; setReleased(false); const current = paneRef.current; if (!current) return; @@ -1553,28 +1792,37 @@ export function PaneTerminal({ // the pane's screen starts again from the new attach, as on a pane switch const generation = ++outputGeneration; // Reset in stream order: synchronous reset would let old queued writes draw afterward. - term.write("\x1bc", () => { - if (disposed || paneRef.current !== current || generation !== outputGeneration) return; + if (!cachedReleaseRef.current) { + term.write("\x1bc", () => { + if (disposed || paneRef.current !== current || generation !== outputGeneration) return; + modifyOtherKeysRef.current = 0; + }); modifyOtherKeysRef.current = 0; - }); - modifyOtherKeysRef.current = 0; + } + cachedReleaseRef.current = false; + setInputReady(false); setOutputReady(false); - if (!observeRef.current && !chatViewRef.current) { + if (!observeRef.current && !chatViewRef.current && shownRef.current && !keyboardHolds(true)) { try { fit.fit(); + fitted(); } catch { /* not laid out yet; the ResizeObserver will follow up */ } } const away = !inUse(); - socket.attach(current, term.cols, term.rows, chatViewRef.current || away || !shownRef.current); + // under the keyboard the attach adopts the pane's size (keep_size) and the catch-up fit takes it + socket.attach(current, term.cols, term.rows, chatViewRef.current || away || !shownRef.current || keyboardHeld); if (away) leave(); }; - // out of use, a reconnect attaches at the size the pane has instead of taking it - // (keepSize, as under the chat lens), until the tab lets go of the pane + // Existing attaches keep their grid while away. New connections watch instead, except + // for queued input, mirrors, embedded pages and bridges without watch support. const leave = (): void => { + if (cachedRef.current) { release(); return; } const current = paneRef.current; - if (current && !observeRef.current && !fixedGridRef.current) socket.keepSize(current); + // A visible but unfocused window still shows the terminal and can receive its grid back. + // Blur changes only reconnect policy; hiding the document also releases its server grid. + if (current && !observeRef.current && !fixedGridRef.current) socket.keepSize(current, document.visibilityState === "hidden"); if (releaseAwayRef.current && releaseTimer === null && !releasedRef.current) releaseTimer = window.setTimeout(() => { if (paneRef.current === current) release(); }, RELEASE_AFTER_MS); @@ -1582,13 +1830,38 @@ export function PaneTerminal({ if (hiddenOnTouch()) release(); }; leaveRef.current = leave; + // The PC's connection stopped on an overload close (4008), or an attach conflicted: attach again + // in place, as a pane pick does. The attach reconnects, and the connection's open replays every + // pane this PC has attached; their snapshot clears what is left of the notice. + const reattach = (): void => { + if (!presentedRef.current || !paneRef.current) return; + stalledRef.current = false; + setOutputError(null); + setEnded(false); + resume(); + term.options.disableStdin = observeRef.current || heldRef.current; + }; + reattachRef.current = reattach; const back = (): void => { if (!presentedRef.current) return; - if (inUse() && awayReleased) awayReleased.current = false; cancelLeave(); - if (releasedRef.current) resume(); + // A pane that kept its attach while put away (a queued message, a mirror) stopped because its + // frozen page sent no ACKs, not because this device is too slow: coming back attaches again, + // once. A stop while the page stays in use is final (no reconnect loop). + if (stalledRef.current && socket.stoppedWhileHidden && inUse()) reattach(); + else if (releasedRef.current) resume(); else refit(); }; + presentationRef.current = () => { + if (cachedRef.current) release(); + else if (presentedRef.current && releasedRef.current) { + if (inUse() || mirroredRef.current) back(); + else if ((cachedReleaseRef.current || deferredAttach && !watchingRef.current) && paneRef.current) { + if (!deferAttach(paneRef.current)) back(); + } + else if (paneRef.current && !watchingRef.current) watch(paneRef.current); + } + }; // A phone or tablet put away lets go of the pane at once, whatever the setting: its page can // freeze before a timer runs, and its attach would keep herdr's own window at the phone's size. const hiddenOnTouch = (): boolean => coarseRef.current && document.visibilityState === "hidden"; @@ -1602,16 +1875,25 @@ export function PaneTerminal({ // Another device can size the pty while this window keeps its focus (a phone used beside a // PC left in front): no focus event comes back here, so a click or key in this terminal // takes the pane back to this window's grid. - // Only input meant for the terminal itself: a press or key in the find bar, on the scrollbar - // or the key bar, a right-click for the pane menu, a held key's repeats or an IME composition + // Only input meant for the terminal itself: a press or key in the find bar or the key bar, + // a right-click for the pane menu, a held key's repeats or an IME composition // must not resize the pty under the other device. - const reclaim = (event: Event): void => { + // A phone's own input paths sit outside the grid (the input line, its Send and Enter, a key the + // key bar sends, not a press on it), so they call this before they send: the reply is then + // drawn for the grid that shows it. + const reassertIfStale = (): void => { const shared = sharedGridRef.current; - if (!shared || releasedRef.current || !inUse() || !(event.target instanceof Node) || !term.element?.contains(event.target)) return; + if (!shared || releasedRef.current || !inUse()) return; + if (shared.cols !== term.cols || shared.rows !== term.rows) refit(); + }; + reassertIfStaleRef.current = reassertIfStale; + const reclaim = (event: Event): void => { + if (!(event.target instanceof Node) || !term.element?.contains(event.target)) return; // an IME's first key comes before compositionstart, with isComposing still false (keyCode 229) if (event instanceof KeyboardEvent && (event.repeat || event.isComposing || event.keyCode === 229 || composingRef.current || compositionCommitPendingRef.current)) return; if (event instanceof PointerEvent && event.button !== 0) return; - if (shared.cols !== term.cols || shared.rows !== term.rows) refit(); + if (releasedRef.current) back(); + else reassertIfStale(); }; // a click or a tap is the user here, whatever the window says about its focus const onPointer = (event: PointerEvent): void => { @@ -1647,14 +1929,20 @@ export function PaneTerminal({ selectionChange.dispose(); if (releaseTimer !== null) window.clearTimeout(releaseTimer); releaseRef.current = () => {}; + presentationRef.current = () => {}; leaveRef.current = () => {}; cancelLeaveRef.current = () => {}; - watchRef.current = () => {}; + reattachRef.current = () => {}; + keyboardHoldsRef.current = () => false; + fittedRef.current = () => {}; + deferAttachRef.current = () => false; + offBeforeReplay(); onWatchedResize.dispose(); window.removeEventListener("focus", back); window.removeEventListener("blur", leave); window.removeEventListener("pointerdown", onPointer, { capture: true }); window.removeEventListener("keydown", reclaim, { capture: true }); + reassertIfStaleRef.current = () => undefined; document.removeEventListener("visibilitychange", onVisibility); onModifiedEnter.dispose(); onCommandBackspace.dispose(); @@ -1694,15 +1982,6 @@ export function PaneTerminal({ if (term) term.options.theme = terminalTheme(theme, palette); }, [theme, palette]); - // the cursor's shape and blink follow too; a program's own DECSCUSR still overrides them until - // the next change here, as it already could when they were fixed - useEffect(() => { - const term = termRef.current; - if (!term) return; - term.options.cursorStyle = terminalCursorStyle; - term.options.cursorBlink = terminalCursorBlink; - }, [terminalCursorStyle, terminalCursorBlink]); - // the font follows too, and a font change moves the grid. xterm measures the cell (and the DOM // renderer its glyph widths) when fontFamily or fontSize changes, and setting the same value // again changes nothing: so a chosen family is set only once its faces loaded, or the cell would @@ -1719,9 +1998,10 @@ export function PaneTerminal({ if (term.options.fontSize === terminalFontSize && term.options.fontFamily === fontFamily) return; term.options.fontSize = terminalFontSize; term.options.fontFamily = fontFamily; - if (observeRef.current || fixedGridRef.current || chatViewRef.current) return; + if (observeRef.current || fixedGridRef.current || chatViewRef.current || keyboardHoldsRef.current(true)) return; try { fitRef.current?.fit(); + fittedRef.current(); } catch { return; } @@ -1740,9 +2020,10 @@ export function PaneTerminal({ if (narrowFontFitted.current === narrowFont) return; narrowFontFitted.current = narrowFont; const term = termRef.current; - if (!term || observeRef.current || fixedGridRef.current || chatViewRef.current) return; + if (!term || observeRef.current || fixedGridRef.current || chatViewRef.current || keyboardHoldsRef.current(false)) return; try { fitRef.current?.fit(); + fittedRef.current(); } catch { return; } @@ -1755,7 +2036,8 @@ export function PaneTerminal({ useEffect(() => { if (chatView) { const pane = paneRef.current; - if (pane) socketRef.current?.keepSize(pane); + // a grid this tab drove goes back to a terminal still showing on another device (#472) + if (pane) socketRef.current?.releaseSize(pane); // the grid another device left the pty at while this one showed the terminal const shared = sharedGridRef.current; const hidden = termRef.current; @@ -1763,9 +2045,12 @@ export function PaneTerminal({ return; } if (observeRef.current || fixedGridRef.current) return; + // the composer's keyboard may still be up as the terminal lens appears: its grid waits for it to go + if (keyboardHoldsRef.current(true)) return; const term = termRef.current; try { fitRef.current?.fit(); + fittedRef.current(); } catch { return; } @@ -1785,9 +2070,11 @@ export function PaneTerminal({ const pane = paneRef.current; if (!pane || observeRef.current || fixedGridRef.current || chatViewRef.current) return; if (!shown) { socketRef.current?.keepSize(pane); return; } + if (keyboardHoldsRef.current(true)) return; const term = termRef.current; try { fitRef.current?.fit(); + fittedRef.current(); } catch { return; } @@ -1801,7 +2088,6 @@ export function PaneTerminal({ const fit = fitRef.current; if (!socket || !term) return; setEnded(false); - setMouseReporting(false); setInputReady(false); setInputError(null); setComposing(false); @@ -1838,27 +2124,24 @@ export function PaneTerminal({ } // Keep connection proof until the server confirms the detach as held. }; - // A fresh keyed terminal inherits a prior release only within this PC's canvas and - // while the browser remains away. Initial inactive loads still attach with keep_size - // and get their normal release delay; no queued input or connection state is inherited. - // a phone put away releases whatever the setting (#748), so its replacement inherits that too - const releasing = releaseAwayRef.current || (coarseRef.current && document.visibilityState === "hidden"); - if (awayReleased && (inUse() || !releasing)) awayReleased.current = false; - if (releasing && !inUse() && awayReleased?.current) setReleased(true); - // A tab that let go watches its replacement until the user returns. - if (releasedRef.current) { - watchRef.current(paneId); - return leavePane; - } - try { - fit?.fit(); - } catch { - /* not laid out yet; the ResizeObserver will follow up */ + // A fresh background pane must not acquire size ownership even with the release setting off. + // This also covers a replacement pane: no prior release or input ownership is inherited. + setReleased(false); + if (deferAttachRef.current(paneId)) return leavePane; + // under the keyboard (a pane picked while typing) the fit waits for it to go, and the attach adopts + // the pane's size meanwhile + const held = keyboardHoldsRef.current(true); + if (!held) { + try { + fit?.fit(); + fittedRef.current(); + } catch { + /* not laid out yet; the ResizeObserver will follow up */ + } } - // out of use (the pane closed in herdr and the app moved on to the next one, or a reload behind - // another app), the attach adopts the pane's size; the refit takes it once the user is here + // A pane exempt from watching still asks to keep an existing attachment's size while away. const away = !inUse(); - socket.attach(paneId, term.cols, term.rows, chatViewRef.current || away || !shownRef.current); + socket.attach(paneId, term.cols, term.rows, chatViewRef.current || away || !shownRef.current || held); // no blur or hidden comes for a tab already out of use: it lets go of this pane after a moment too if (away) leaveRef.current(); // the chat lens covers the grid and its composer takes the keyboard: focusing the hidden @@ -1869,6 +2152,7 @@ export function PaneTerminal({ // the message the tab kept its pane for has gone (or was held): out of use, it lets go now useEffect(() => { releaseRef.current(); }, [pending]); + useEffect(() => { presentationRef.current(); }, [cached, presented, mirrored]); // A sibling already mounted in the split canvas becomes the keyboard target only on an @@ -1899,6 +2183,7 @@ export function PaneTerminal({ const input = (modified ? navigationSequence(item.key, active) : null) ?? keyBarInputSequence(item.key, term.modes.applicationCursorKeysMode); if (input === null) return; + reassertIfStaleRef.current(); barKeyRef.current = { key: item.key, chord }; try { term.input(input); } finally { barKeyRef.current = null; } // with the input line, the keyboard belongs to it: a key tap must not move it to the grid @@ -1941,15 +2226,17 @@ export function PaneTerminal({ // must not turn a one-letter message into a control key. Offline it sends nothing and // keeps its text (never-queue); a message the server could not deliver keeps it too, // with the reason. Bracketed-paste wrapping follows the pane program's mode. - const submitComposerMessage = useCallback((text: string, delivery: SubmitDelivery = "immediate", clearDraft?: string): Promise | null => { + const submitComposerMessage = useCallback((text: string, delivery: SubmitDelivery = "immediate", clearDraft?: string, sendAnyway = false, resendId?: string, generation?: number): Promise | null => { const term = termRef.current; const socket = socketRef.current; const session = sessionRef.current; const pane = paneRef.current; if (!term || !socket || !session || pane === null || secretRef.current !== null || heldRef.current) return null; + // its Enter would run the pty's line without the start a held draft keeps + if (draftHeldForRef.current === pane) return Promise.resolve({ ok: false, code: "held_input", message: "send or discard the held input first" }); // not the scope itself: a message sent right after a reconnect leaves before the snapshot that names it const epoch = session.epoch; - const sent = socket.submit(pane, composerMessage(text), composerPayload(text, term.modes.bracketedPasteMode), false, delivery, clearDraft); + const sent = socket.submit(pane, composerMessage(text), composerPayload(text, term.modes.bracketedPasteMode), false, delivery, clearDraft, sendAnyway, resendId, generation); if (sent === null) return null; const releaseSubmit = submitRetention?.retain(pane); const submission = delivery !== "immediate" ? { pane, socket, epoch } : null; @@ -2003,8 +2290,11 @@ export function PaneTerminal({ const socket = socketRef.current; const pane = paneRef.current; if (!term || !socket || pane === null || secretRef.current !== null) return false; + // its text and Enter would land beside the held draft: Send or Discard that first + if (draftHeldForRef.current === pane) return Promise.resolve(tRef.current("Not sent: send or discard the held input first.")); const message = composerMessage(text); const payload = message.includes("\n") ? composerPayload(text, term.modes.bracketedPasteMode) : message; + reassertIfStaleRef.current(); const sent = socket.submit(pane, message, payload, true); if (sent === null) return false; const releaseSubmit = submitRetention?.retain(pane); @@ -2015,10 +2305,16 @@ export function PaneTerminal({ }); }, [submitRetention]); - const pressEnter = useCallback((): boolean => { + // a tap into the input line is the user here: the grid is theirs again before they type + const reassertGrid = useCallback(() => { reassertIfStaleRef.current(); }, []); + + const pressEnter = useCallback((): boolean | string => { const socket = socketRef.current; const pane = paneRef.current; if (!socket || pane === null || !socket.connected) return false; + // Enter beside a held draft would run the pty's line without the held start + if (draftHeldForRef.current === pane) return tRef.current("Not sent: send or discard the held input first."); + reassertIfStaleRef.current(); const sent = hasModifiers(modifiersRef.current) ? socket.sendKeys(pane, [terminalChord("Enter", modifiersRef.current)!]) : socket.sendInput(pane, "\r"); termRef.current?.scrollToBottom(); @@ -2026,7 +2322,7 @@ export function PaneTerminal({ }, []); const toggleDirect = useCallback(() => { - if (composingRef.current) return; + if (composingRef.current || lineComposingRef.current) return; updateSettings({ terminalInputMode: directTyping ? "line" : "direct" }); }, [directTyping, updateSettings]); @@ -2054,7 +2350,8 @@ export function PaneTerminal({ const term = termRef.current; const socket = socketRef.current; if (!term || !socket || !socket.connected) return; - term.input("\u001b"); + stopKeyRef.current = true; + try { term.input("\u001b"); } finally { stopKeyRef.current = false; } }, []); // While the agent runs, append to its held messages. Each requires an explicit send. @@ -2139,8 +2436,49 @@ export function PaneTerminal({ return () => { observer.disconnect(); stack.style.removeProperty("--composer-lift"); stack.style.removeProperty("--composer-room"); setGreetingRoom(true); }; }, [greetingDue, paneId]); + // Under the chat lens, while the input card shows, the pills the terminal lens floats top-right + // (another app holds the pane, the output stream failed, input held for the terminal, a copy + // note) stand over the card instead, as rows of the composer's with a dismiss each. Without the + // card (view only, ended, a secret prompt) they stay pills. The ids carry the pane, so another + // pane's dismissals never carry over; a dismissal goes with its notice, or when the notice says + // something else (lib/composerNotices.ts) + const noticesDocked = presented && paneId !== null && chatView && !secretActive && !observing && !ended; + const noticeItems: ComposerNoticeRow[] = []; + if (paneId !== null) { + const owner = paneStorageId(machineId, paneId); + const takeOver = !observing && socketRef.current?.canTakeOver() === true; + if (held) noticeItems.push({ + id: `${owner}:held`, priority: "urgent", revision: takeOver ? "open-here" : "", warning: true, mark: , + text: t("Another app has this pane open. It connects here as soon as that app lets go."), + actions: takeOver && , + }); + if (outputError) noticeItems.push({ + id: `${owner}:output`, priority: "urgent", revision: outputError, warning: true, mark: , text: outputError, + actions: {t("Reconnect")}, + }); + if (!ended && connected && !draftIsEmpty(draft)) noticeItems.push({ + id: `${owner}:draft`, priority: "activity", revision: `${draft.truncated ? "…" : ""}${draft.text}`, warning: false, mark: , + text: <> + {t("Input held until the terminal is ready:")} + {draft.text.length > 0 && <> {draft.text}} + {draft.truncated && <> {t("Some input was too long to hold and was left out.")}} + , + actions: <> + + + , + }); + if (clipboardNote) noticeItems.push({ id: `${owner}:clipboard`, priority: "notice", revision: clipboardNote, warning: false, mark: , text: clipboardNote }); + } + const settledNotices = settleDismissed(dismissedNotices, noticeItems); + if (settledNotices !== dismissedNotices) setDismissedNotices(settledNotices); + const shownNotices = noticesDocked ? arrangeNotices(noticeItems, settledNotices) : []; + const composerNotices = shownNotices.length > 0 + ? { setDismissedNotices((dismissed) => dismissNotice(dismissed, notice)); }} /> + : null; + const composerSend = useCallback( - (text: string, clearDraft?: string): boolean | string | DraftRefusal | Promise => { + (text: string, clearDraft?: string, sendAnyway = false): boolean | string | DraftRefusal | Promise => { const pane = paneRef.current; // Codex's queue open in the terminal holds the input: a message would become the answer if (pane !== null && heldByOpenQueue) { @@ -2169,54 +2507,69 @@ export function PaneTerminal({ // with automatic sending off, a follow-up waits for Send now; the socket queues it on a PC too old to hold one const queued = composerDelivery(agent, agentStatus); const delivery: SubmitDelivery = queued === "queue" && !settings.autoSendQueued ? "hold" : queued; - const result = submitComposerMessage(text, delivery, delivery === "immediate" ? clearDraft : undefined); - return result === null ? false : result.then((answer) => answer.ok ? true : submitRefusal(answer.code, answer.message, answer.draft)); + // Send anyway answered a box seen at once: an agent that started working since would queue it unchecked + if (sendAnyway && delivery !== "immediate") return t("Not sent: the agent started working since. Check the terminal, then send again."); + const result = submitComposerMessage(text, delivery, delivery === "immediate" ? clearDraft : undefined, sendAnyway); + return result === null ? false : result.then((answer) => answer.ok ? true : submitRefusal(answer.code, answer.message, answer.draft, answer.box)); }, [agent, agentStatus, answerPanePrompt, answering, heldByOpenQueue, submitComposerMessage, queueStore, machineId, settings.autoSendQueued], ); const actOnPending = useCallback(async (id: string, action: "steer" | "discard"): Promise => { - const owner = queueOwner, pane = paneId; - const socket = socketRef.current, scope = pendingScopeRef.current; - if (owner === null || pane === null || paneRef.current !== pane) return; - // what another tab saved since: a copy it is sending again is not confirmed here either - pendingMessages.refresh(owner); - const message = pendingMessages.read(owner).find((item) => item.id === id); - if (!message || message.state === "sending") return; - if (action === "steer" && (message.state === "uncertain" || !socket?.connected || heldRef.current || secretRef.current !== null || observeRef.current || ended || heldByOpenQueue || answering !== null)) return; - if (message.serverOwned && !pendingMessages.isOwned(owner, id, scope)) return; - if (!pendingMessages.begin(owner, id)) return; - try { - if (message.serverOwned) { - const result = socket?.pendingAction(pane, id, action); - if (!result) { - pendingMessages.fail(owner, id, { code: "disconnected", message: t("Not confirmed. Check the terminal before sending again.") }, true); - return; - } - // as a composer send: the pane keeps its connection and lease until the receipt settles - const releaseSubmit = submitRetention?.retain(pane); - const answer = await result.finally(() => releaseSubmit?.()); - if (!answer.ok) pendingMessages.fail(owner, id, { code: answer.code, message: answer.message }, answer.code === "pending_not_found" || !submitNotTyped(answer.code)); - // Only the matching server removal receipt removes an authoritative item. - } else if (action === "discard") { - pendingMessages.removeCopy(owner, id); - } else if (message.state === "held") { - if (!pendingMessages.unconfirm(owner, id)) { - pendingMessages.fail(owner, id, { code: "unsaved", message: t("Queue could not be saved. Keep this tab open or copy the messages before reloading.") }, false); - return; - } - const result = submitComposerMessage(message.text, "immediate"); - if (!result) { - pendingMessages.fail(owner, id, { code: "disconnected", message: t("Not sent. Reconnect and try again.") }, false); - return; - } - const answer = await result; - if (answer.ok) pendingMessages.removeCopy(owner, id); - else pendingMessages.fail(owner, id, { code: answer.code, message: answer.message }, !submitNotTyped(answer.code)); + const perform = async (): Promise => { + const owner = queueOwner, pane = paneId; + const socket = socketRef.current, scope = pendingScopeRef.current; + if (owner === null || pane === null || paneRef.current !== pane) return; + // what another tab saved since: a copy it is sending again is not confirmed here either + pendingMessages.refresh(owner); + const message = pendingMessages.read(owner).find((item) => item.id === id); + if (!message || message.state === "sending") return; + if (action === "steer" && (message.state === "uncertain" || !socket?.connected || heldRef.current || secretRef.current !== null || observeRef.current || ended || heldByOpenQueue || answering !== null)) return; + if (message.serverOwned && !pendingMessages.isOwned(owner, id, scope)) return; + // keys held back in the terminal draft would run in front of the message: the server pastes a + // server-owned row's text and sends Enter without looking for a partial line (only Claude's box + // is checked there). The row stays as it is, so Send now after the draft is settled, or Discard, still works. + if (action === "steer" && draftHeldForRef.current === pane) { + pendingMessages.fail(owner, id, { code: "held_input", message: "send or discard the held input first" }, false); + return; } - } catch { - pendingMessages.fail(owner, id, { code: "disconnected", message: t("Not confirmed. Check the terminal before sending again.") }, true); - } finally { pendingMessages.end(owner, id); } + if (!pendingMessages.begin(owner, id)) return; + try { + if (message.serverOwned) { + const result = socket?.pendingAction(pane, id, action, message.resendGeneration ?? 0); + if (!result) { + pendingMessages.fail(owner, id, { code: "disconnected", message: t("Not confirmed. Check the terminal before sending again.") }, true); + return; + } + // as a composer send: the pane keeps its connection and lease until the receipt settles + const releaseSubmit = submitRetention?.retain(pane); + const answer = await result.finally(() => releaseSubmit?.()); + if (!answer.ok) pendingMessages.fail(owner, id, { code: answer.code, message: answer.message }, answer.code === "pending_not_found" || !submitNotTyped(answer.code), + action === "steer" ? message.resendGeneration ?? 0 : undefined); + // Only the matching server removal receipt removes an authoritative item. + } else if (action === "discard") { + pendingMessages.removeCopy(owner, id); + } else if (message.state === "held") { + if (!pendingMessages.unconfirm(owner, id)) { + return; + } + const result = submitComposerMessage(message.text, "immediate", undefined, false, id, message.resendGeneration ?? 0); + if (!result) { + pendingMessages.fail(owner, id, { code: "disconnected", message: t("Not sent. Reconnect and try again.") }, false); + return; + } + const answer = await result; + if (answer.ok) pendingMessages.removeCopy(owner, id); + else pendingMessages.fail(owner, id, { code: answer.code, message: answer.message }, !submitNotTyped(answer.code), message.resendGeneration ?? 0); + } + } catch { + pendingMessages.fail(owner, id, { code: "disconnected", message: t("Not confirmed. Check the terminal before sending again.") }, true); + } finally { pendingMessages.end(owner, id); } + }; + // Web Locks reduce contention where supported; the bridge's stable resend ID is the + // authority on LAN HTTP and when two tabs have already read the same held copy. + if (navigator.locks) await navigator.locks.request(`pending:${queueOwner}:${id}`, perform); + else await perform(); }, [answering, ended, heldByOpenQueue, paneId, queueOwner, submitComposerMessage, submitRetention]); @@ -2224,7 +2577,7 @@ export function PaneTerminal({ const uploadImage = useCallback((file: File) => uploadPaneImage(paneId ?? "", file), [paneId]); useEffect(() => { - if (!active || !paneId || !settings.chatPageKeys) return; + if (!active || !paneId) return; const onPage = (event: KeyboardEvent) => { const direction = plainPageDirection(event); if (direction === null || event.defaultPrevented || !activeRef.current || !presentedRef.current || !chatViewRef.current || modalOpen() @@ -2237,7 +2590,7 @@ export function PaneTerminal({ }; window.addEventListener("keydown", onPage, true); return () => window.removeEventListener("keydown", onPage, true); - }, [active, paneId, settings.chatPageKeys]); + }, [active, paneId]); const sendApplicationPage = useCallback((direction: -1 | 1) => { const pane = paneRef.current, socket = socketRef.current; @@ -2260,6 +2613,35 @@ export function PaneTerminal({ return () => { if (controls.current === current) controls.current = null; }; }, [controls, active, paneId]); + const secretInput = presented && paneId !== null && secretActive && !observing && !ended && connected && outputReady ? socketRef.current?.sendSecret(paneId, secret.prompt, value) ?? null} + onCancel={() => { if (socketRef.current?.connected) socketRef.current.sendInput(paneId, "\u0003"); }} + /> : null; + // the input line's row, kept while the masked input stands in for it + const lineSlot = presented && paneId !== null && !observing && !ended && inputLine; + // What floats from the slot may reach up to the grid's top and no further (TerminalInput.css): + // in a short pane (a split, a phone in landscape with its keyboard up) a grown line or the masked + // input would otherwise cover the find bar, or be clipped under the pane's frame + const [inputSlot, setInputSlot] = useState(null); + useLayoutEffect(() => { + const surface = stackRef.current?.querySelector(".terminal-surface"); + if (!stackRef.current || !surface || !inputSlot) return; + const measure = () => { + const slot = inputSlot.getBoundingClientRect(); + // the chat lens's box-less slot has no rect, and nothing floats there to cap + if (slot.width === 0) return; + const room = slot.bottom - surface.getBoundingClientRect().top; + inputSlot.style.setProperty("--terminal-input-room", `${Math.max(0, Math.floor(room))}px`); + }; + measure(); + // the surface moves with the find bar and resizes with the stack and the key bar under it + const observer = new ResizeObserver(measure); + observer.observe(stackRef.current); + observer.observe(surface); + return () => observer.disconnect(); + }, [inputSlot]); + return ( // data-direct-typing: xterm's own field raises the soft keyboard here (lib/viewport.ts)
    @@ -2285,7 +2667,7 @@ export function PaneTerminal({
    )}
    - {paneId !== null && held && ( + {paneId !== null && held && !noticesDocked && (
    {t("Another app has this pane open. It connects here as soon as that app lets go.")} {!observing && socketRef.current?.canTakeOver() && ( @@ -2298,10 +2680,10 @@ export function PaneTerminal({ {t("Live terminal is coming to Windows PCs: herdr cannot attach a terminal there yet. The chat lens works now.")}
    )} - {paneId !== null && outputError && ( + {paneId !== null && outputError && !noticesDocked && (
    {outputError} - {t("Reconnect")} +
    )} {!chatView && inputError &&
    {inputError}
    } @@ -2319,12 +2701,14 @@ export function PaneTerminal({ {draft.text.length > 0 && input held: “{draft.text}”}
    )} - {paneId !== null && !ended && connected && !draftIsEmpty(draft) && ( + {paneId !== null && !ended && connected && !draftIsEmpty(draft) && !noticesDocked && (
    {t("Input held until the terminal is ready:")} {draft.text.length > 0 && {draft.text}} {/* the preview shows the start of the text: without this, a draft that lost its end looks whole */} - {draft.truncated && {t("Some input was too long to hold and was left out.")}} + {draft.truncated && {t("Some input could not be held and was left out.")}} + {/* Enter is never held: without this, two commands look like one line and a paste looks whole */} + {draft.leftOut && {t("An Enter or line break was left out: Send never presses Enter.")}}
    )} - {clipboardNote && ( + {paneId !== null && !chatView && selectionQuote?.pane === paneId && ( + + )} + {clipboardNote && !noticesDocked && (
    {clipboardNote}
    @@ -2355,14 +2755,14 @@ export function PaneTerminal({ visible grid carries the pane's name: a screen reader announces which pane this is. No tabIndex: xterm's helper textarea takes the keyboard here on attach, so a stop on the wrapper would only be an empty one ahead of it. */} -
    +